diff --git a/.github/workflows/analysis-of-endpoint-connections.yml b/.github/workflows/analysis-of-endpoint-connections.yml index 2116605bea0a..f5f2dd4edb71 100644 --- a/.github/workflows/analysis-of-endpoint-connections.yml +++ b/.github/workflows/analysis-of-endpoint-connections.yml @@ -2,7 +2,13 @@ name: Analysis of Endpoint Connections on: workflow_dispatch: - push: + pull_request: + types: + - opened + - synchronize + paths: + - 'src/main/java/**' + - 'src/main/webapp/**' # Keep in sync with build.yml and test.yml and codeql-analysis.yml env: @@ -20,7 +26,7 @@ jobs: with: fetch-depth: 0 - - name: Set up JDK 21 + - name: Set up JDK uses: actions/setup-java@v4 with: java-version: '${{ env.java }}' @@ -59,7 +65,7 @@ jobs: with: fetch-depth: 0 - - name: Set up JDK 21 + - name: Set up JDK uses: actions/setup-java@v4 with: distribution: 'temurin' diff --git a/angular.json b/angular.json index a2c893ff59f6..e5543ff2ce60 100644 --- a/angular.json +++ b/angular.json @@ -21,8 +21,6 @@ "builder": "@angular-devkit/build-angular:application", "options": { "allowedCommonJsDependencies": [ - "brace", - "brace/mode/java", "clone-deep", "crypto-js", "crypto", diff --git a/build.gradle b/build.gradle index 8eda5b8226e5..9a023c709654 100644 --- a/build.gradle +++ b/build.gradle @@ -27,7 +27,7 @@ plugins { id "com.github.ben-manes.versions" version "0.51.0" id "com.github.andygoossens.modernizer" version "${modernizer_plugin_version}" id "com.gorylenko.gradle-git-properties" version "2.4.2" - id "org.owasp.dependencycheck" version "10.0.3" + id "org.owasp.dependencycheck" version "10.0.4" id "com.adarshr.test-logger" version "4.0.0" } @@ -284,12 +284,12 @@ dependencies { implementation "org.apache.sshd:sshd-sftp:${sshd_version}" // https://mvnrepository.com/artifact/net.sourceforge.plantuml/plantuml - implementation "net.sourceforge.plantuml:plantuml:1.2024.6" + implementation "net.sourceforge.plantuml:plantuml:1.2024.5" implementation "org.jasypt:jasypt:1.9.3" implementation "me.xdrop:fuzzywuzzy:1.4.0" implementation("org.yaml:snakeyaml") { version { - strictly "2.2" + strictly "2.3" // needed to reduce the number of vulnerabilities, also see https://mvnrepository.com/artifact/org.yaml/snakeyaml } } @@ -330,7 +330,7 @@ dependencies { implementation "tech.jhipster:jhipster-framework:${jhipster_dependencies_version}" implementation "org.springframework.boot:spring-boot-starter-cache:${spring_boot_version}" - implementation "io.micrometer:micrometer-registry-prometheus:1.12.6" + implementation "io.micrometer:micrometer-registry-prometheus:1.13.3" implementation "net.logstash.logback:logstash-logback-encoder:8.0" // Defines low-level streaming API, and includes JSON-specific implementations @@ -349,13 +349,19 @@ dependencies { implementation "com.hazelcast:hazelcast:${hazelcast_version}" implementation "com.hazelcast:hazelcast-spring:${hazelcast_version}" implementation "com.hazelcast:hazelcast-hibernate53:5.2.0" + implementation "javax.cache:cache-api:1.1.1" implementation "org.hibernate.orm:hibernate-core:${hibernate_version}" + implementation "com.zaxxer:HikariCP:5.1.0" + implementation "org.apache.commons:commons-text:1.12.0" implementation "org.apache.commons:commons-math3:3.6.1" + implementation "javax.transaction:javax.transaction-api:1.3" + implementation "org.liquibase:liquibase-core:${liquibase_version}" + implementation "org.springframework.boot:spring-boot-starter-validation:${spring_boot_version}" implementation "org.springframework.boot:spring-boot-loader-tools:${spring_boot_version}" implementation "org.springframework.boot:spring-boot-starter-mail:${spring_boot_version}" @@ -421,12 +427,12 @@ dependencies { } implementation "io.springfox:springfox-bean-validators:3.0.0" implementation "com.mysql:mysql-connector-j:9.0.0" - implementation "org.postgresql:postgresql:42.7.3" + implementation "org.postgresql:postgresql:42.7.4" implementation "org.zalando:problem-spring-web:0.29.1" implementation "org.zalando:jackson-datatype-problem:0.27.1" implementation "com.ibm.icu:icu4j-charset:75.1" - implementation "com.github.seancfoley:ipaddress:5.5.0" + implementation "com.github.seancfoley:ipaddress:5.5.1" implementation "org.apache.maven:maven-model:3.9.9" // NOTE: 3.0.2 is broken for splitting lecture specific PDFs implementation "org.apache.pdfbox:pdfbox:3.0.1" @@ -442,6 +448,9 @@ dependencies { // use newest version of gson to avoid security issues through outdated dependencies implementation "com.google.code.gson:gson:2.11.0" + + implementation "com.google.errorprone:error_prone_annotations:2.31.0" + annotationProcessor "org.hibernate:hibernate-jpamodelgen:${hibernate_version}" annotationProcessor("org.glassfish.jaxb:jaxb-runtime:${jaxb_runtime_version}") { exclude group: "jakarta.ws.rs", module: "jsr311-api" @@ -474,7 +483,7 @@ dependencies { testImplementation "org.awaitility:awaitility:4.2.2" testImplementation "org.apache.maven.shared:maven-invoker:3.3.0" testImplementation "org.gradle:gradle-tooling-api:8.10" - testImplementation "org.apache.maven.surefire:surefire-report-parser:3.4.0" + testImplementation "org.apache.maven.surefire:surefire-report-parser:3.5.0" testImplementation "com.opencsv:opencsv:5.9" testImplementation("io.zonky.test:embedded-database-spring-test:2.5.1") { exclude group: "org.testcontainers", module: "mariadb" @@ -486,7 +495,7 @@ dependencies { } testImplementation("net.bytebuddy:byte-buddy") { version { - strictly "1.14.19" + strictly "1.15.1" } } // cannot update due to "Syntax error in SQL statement "WITH ids_to_delete" diff --git a/docs/user/exercises/programming-exercise-features.inc b/docs/user/exercises/programming-exercise-features.inc index e73c16088efe..3675a9e4c2b0 100644 --- a/docs/user/exercises/programming-exercise-features.inc +++ b/docs/user/exercises/programming-exercise-features.inc @@ -33,6 +33,8 @@ Instructors can still use those templates to generate programming exercises and +----------------------+----------+---------+ | OCaml | yes | no | +----------------------+----------+---------+ + | Rust | yes | yes | + +----------------------+----------+---------+ - Not all ``templates`` support the same feature set and supported features can also change depending on the continuous integration system setup. Depending on the feature set, some options might not be available during the creation of the programming exercise. @@ -63,6 +65,8 @@ Instructors can still use those templates to generate programming exercises and +----------------------+----------------------+----------------------+---------------------+--------------+------------------------------------------+------------------------------+----------------------------+------------------------+ | OCaml | no | no | no | no | n/a | yes | no | L: yes, J: no | +----------------------+----------------------+----------------------+---------------------+--------------+------------------------------------------+------------------------------+----------------------------+------------------------+ + | Rust | no | no | no | no | n/a | no | no | L: yes, J: no | + +----------------------+----------------------+----------------------+---------------------+--------------+------------------------------------------+------------------------------+----------------------------+------------------------+ - *Sequential Test Runs*: ``Artemis`` can generate a build plan which first executes structural and then behavioral tests. This feature can help students to better concentrate on the immediate challenge at hand. - *Static Code Analysis*: ``Artemis`` can generate a build plan which additionally executes static code analysis tools. diff --git a/gradle.properties b/gradle.properties index cfff6f39ed14..80dbe5279b3e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,23 +6,22 @@ node_version=20.14.0 npm_version=10.7.0 # Dependency versions -jhipster_dependencies_version=8.6.0 -spring_boot_version=3.3.2 -spring_security_version=6.3.2 -# TODO: before we upgrade to 6.5.x, we need to make sure that there are no performance issues with empty sets or lists -hibernate_version=6.4.9.Final +jhipster_dependencies_version=8.7.0 +spring_boot_version=3.3.3 +spring_security_version=6.3.3 +# TODO: upgrading to 6.6.0 currently leads to issues due to internal changes in Hibernate and potentially wrong use in Artemis server code +hibernate_version=6.4.10.Final # TODO: can we update to 5.x? opensaml_version=4.3.2 jwt_version=0.12.6 jaxb_runtime_version=4.0.5 -# TODO: we cannot update to 5.5.0 because we currently use the CP Subsystem for fenced locks, however CP Subsystem is only available to Enterprise customers -hazelcast_version=5.4.0 +hazelcast_version=5.5.0 junit_version=5.10.2 -mockito_version=5.12.0 +mockito_version=5.13.0 fasterxml_version=2.17.2 jgit_version=6.10.0.202406032230-r sshd_version=2.13.2 -checkstyle_version=10.17.0 +checkstyle_version=10.18.1 jplag_version=5.1.0 slf4j_version=2.0.16 sentry_version=7.14.0 diff --git a/package-lock.json b/package-lock.json index 4ac4c8f4a4b4..e0f6197e7588 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,18 +10,18 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { - "@angular/animations": "18.2.1", - "@angular/cdk": "18.2.1", - "@angular/common": "18.2.1", - "@angular/compiler": "18.2.1", - "@angular/core": "18.2.1", - "@angular/forms": "18.2.1", - "@angular/localize": "18.2.1", - "@angular/material": "18.2.1", - "@angular/platform-browser": "18.2.1", - "@angular/platform-browser-dynamic": "18.2.1", - "@angular/router": "18.2.1", - "@angular/service-worker": "18.2.1", + "@angular/animations": "18.2.2", + "@angular/cdk": "18.2.2", + "@angular/common": "18.2.2", + "@angular/compiler": "18.2.2", + "@angular/core": "18.2.2", + "@angular/forms": "18.2.2", + "@angular/localize": "18.2.2", + "@angular/material": "18.2.2", + "@angular/platform-browser": "18.2.2", + "@angular/platform-browser-dynamic": "18.2.2", + "@angular/router": "18.2.2", + "@angular/service-worker": "18.2.2", "@ctrl/ngx-emoji-mart": "9.2.0", "@danielmoncada/angular-datetime-picker": "18.1.0", "@fingerprintjs/fingerprintjs": "4.4.3", @@ -31,16 +31,14 @@ "@fortawesome/free-regular-svg-icons": "6.6.0", "@fortawesome/free-solid-svg-icons": "6.6.0", "@ls1intum/apollon": "3.3.14", - "@ng-bootstrap/ng-bootstrap": "17.0.0", + "@ng-bootstrap/ng-bootstrap": "17.0.1", "@ngx-translate/core": "15.0.0", "@ngx-translate/http-loader": "8.0.0", - "@sentry/angular": "8.26.0", + "@sentry/angular": "8.27.0", "@swimlane/ngx-charts": "20.5.0", "@swimlane/ngx-graph": "8.4.0", "@vscode/codicons": "0.0.36", - "ace-builds": "1.36.0", "bootstrap": "5.3.3", - "brace": "0.11.1", "compare-versions": "6.1.1", "core-js": "3.38.1", "crypto-js": "4.2.0", @@ -61,12 +59,12 @@ "ngx-infinite-scroll": "18.0.0", "ngx-webstorage": "18.0.0", "papaparse": "5.4.1", - "posthog-js": "1.157.2", + "posthog-js": "1.160.0", "rxjs": "7.8.1", "showdown": "2.1.0", "showdown-highlight": "3.1.0", "showdown-katex": "0.6.0", - "simple-statistics": "7.8.4", + "simple-statistics": "7.8.5", "smoothscroll-polyfill": "0.4.4", "sockjs-client": "1.6.1", "split.js": "1.6.5", @@ -79,33 +77,33 @@ }, "devDependencies": { "@angular-builders/jest": "18.0.0", - "@angular-devkit/build-angular": "18.2.1", + "@angular-devkit/build-angular": "18.2.2", "@angular-eslint/builder": "18.3.0", "@angular-eslint/eslint-plugin": "18.3.0", "@angular-eslint/eslint-plugin-template": "18.3.0", "@angular-eslint/schematics": "18.3.0", "@angular-eslint/template-parser": "18.3.0", - "@angular/cli": "18.2.1", - "@angular/compiler-cli": "18.2.1", - "@angular/language-service": "18.2.1", - "@sentry/types": "8.26.0", + "@angular/cli": "18.2.2", + "@angular/compiler-cli": "18.2.2", + "@angular/language-service": "18.2.2", + "@sentry/types": "8.27.0", "@types/crypto-js": "4.2.2", "@types/d3-shape": "3.1.6", "@types/dompurify": "3.0.5", "@types/jest": "29.5.12", "@types/lodash-es": "4.17.12", - "@types/node": "22.5.0", + "@types/node": "22.5.1", "@types/papaparse": "5.3.14", "@types/showdown": "2.0.6", "@types/smoothscroll-polyfill": "0.3.4", "@types/sockjs-client": "1.5.4", "@types/uuid": "10.0.0", - "@typescript-eslint/eslint-plugin": "8.2.0", - "@typescript-eslint/parser": "8.2.0", + "@typescript-eslint/eslint-plugin": "8.3.0", + "@typescript-eslint/parser": "8.3.0", "eslint": "9.9.1", "eslint-config-prettier": "9.1.0", "eslint-plugin-deprecation": "3.0.0", - "eslint-plugin-jest": "28.8.0", + "eslint-plugin-jest": "28.8.1", "eslint-plugin-jest-extended": "2.4.0", "eslint-plugin-prettier": "5.2.1", "folder-hash": "4.0.4", @@ -118,7 +116,7 @@ "jest-junit": "16.0.0", "jest-preset-angular": "14.2.2", "lint-staged": "15.2.9", - "ng-mocks": "14.13.0", + "ng-mocks": "14.13.1", "prettier": "3.3.3", "sass": "1.77.8", "ts-jest": "29.2.5", @@ -211,13 +209,13 @@ } }, "node_modules/@angular-devkit/architect": { - "version": "0.1802.1", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1802.1.tgz", - "integrity": "sha512-XTnJfCBMDQl3xF4w/eNrq821gbj2Ig1cqbzpRflhz4pqrANTAfHfPoIC7piWEZ60FNlHapzb6fvh6tJUGXG9og==", + "version": "0.1802.2", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1802.2.tgz", + "integrity": "sha512-LPRl9jhcf0NgshaL6RoUy1uL/cAyNt7oxctoZ9EHUu8eh5E9W/jZGhVowjOLpirwqYhmEzKJJIeS49Ssqs3RQg==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "18.2.1", + "@angular-devkit/core": "18.2.2", "rxjs": "7.8.1" }, "engines": { @@ -227,17 +225,17 @@ } }, "node_modules/@angular-devkit/build-angular": { - "version": "18.2.1", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-18.2.1.tgz", - "integrity": "sha512-ANsTWKjIlEvJ6s276TbwnDhkoHhQDfsNiRFUDRGBZu94UNR78ImQZSyKYGHJOeQQH6jpBtraA1rvW5WKozAtlw==", + "version": "18.2.2", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-18.2.2.tgz", + "integrity": "sha512-7HEnTN2T1jnjuItXKcApOsoYGgfou4+POju3ZbwIQukDZ3B2COskvQkVTxqPNrQ0ZjT2mxZYoVlmGW9M+7N25g==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.1802.1", - "@angular-devkit/build-webpack": "0.1802.1", - "@angular-devkit/core": "18.2.1", - "@angular/build": "18.2.1", + "@angular-devkit/architect": "0.1802.2", + "@angular-devkit/build-webpack": "0.1802.2", + "@angular-devkit/core": "18.2.2", + "@angular/build": "18.2.2", "@babel/core": "7.25.2", "@babel/generator": "7.25.0", "@babel/helper-annotate-as-pure": "7.24.7", @@ -248,7 +246,7 @@ "@babel/preset-env": "7.25.3", "@babel/runtime": "7.25.0", "@discoveryjs/json-ext": "0.6.1", - "@ngtools/webpack": "18.2.1", + "@ngtools/webpack": "18.2.2", "@vitejs/plugin-basic-ssl": "1.1.0", "ansi-colors": "4.1.3", "autoprefixer": "10.4.20", @@ -290,7 +288,7 @@ "tslib": "2.6.3", "vite": "5.4.0", "watchpack": "2.4.1", - "webpack": "5.93.0", + "webpack": "5.94.0", "webpack-dev-middleware": "7.3.0", "webpack-dev-server": "5.0.4", "webpack-merge": "6.0.1", @@ -381,13 +379,13 @@ "license": "0BSD" }, "node_modules/@angular-devkit/build-webpack": { - "version": "0.1802.1", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1802.1.tgz", - "integrity": "sha512-xOP9Hxkj/mWYdMTa/8uNxFTv7z+3UiGdt4VAO7vetV5qkU/S9rRq8FEKviCc2llXfwkhInSgeeHpWKdATa+YIQ==", + "version": "0.1802.2", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1802.2.tgz", + "integrity": "sha512-Pj+YmKh0nJOKl6QAsqYh3SqfuVJrFqjyp5WrG9BgfsMD9GCMD+5teMHNYJlp+vG/C8e7VdZp4rqOon8K9Xn4Mw==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/architect": "0.1802.1", + "@angular-devkit/architect": "0.1802.2", "rxjs": "7.8.1" }, "engines": { @@ -401,9 +399,9 @@ } }, "node_modules/@angular-devkit/core": { - "version": "18.2.1", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-18.2.1.tgz", - "integrity": "sha512-fSuGj6CxiTFR+yjuVcaWqaVb5Wts39CSBYRO1BlsOlbuWFZ2NKC/BAb5bdxpB31heCBJi7e3XbPvcMMJIcnKlA==", + "version": "18.2.2", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-18.2.2.tgz", + "integrity": "sha512-Zz0tGptI/QQnUBDdp+1G5wGwQWMjpfe2oO+UohkrDVgFS71yVj4VDnOy51kMTxBvzw+36evTgthPpmzqPIfxBw==", "dev": true, "license": "MIT", "dependencies": { @@ -429,13 +427,13 @@ } }, "node_modules/@angular-devkit/schematics": { - "version": "18.2.1", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-18.2.1.tgz", - "integrity": "sha512-2t/q0Jcv7yqhAzEdNgsxoGSCmPgD4qfnVOJ7EJw3LNIA+kX1CmtN4FESUS0i49kN4AyNJFAI5O2pV8iJiliKaw==", + "version": "18.2.2", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-18.2.2.tgz", + "integrity": "sha512-PU6+3nX+gQ3gofR7BGwXuvNUNeeV2raURaZjlPfGpBqjyTBxukMV71QsTTWptAZT4WibCWkTFp6X1gvsOGbjMg==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "18.2.1", + "@angular-devkit/core": "18.2.2", "jsonc-parser": "3.3.1", "magic-string": "0.30.11", "ora": "5.4.1", @@ -548,9 +546,9 @@ } }, "node_modules/@angular/animations": { - "version": "18.2.1", - "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-18.2.1.tgz", - "integrity": "sha512-jit452yuE6DMVV09E6RAjgapgw64mMVH31ccpPvMDekzPsTuP3KNKtgRFU/k2DFhYJvyczM1AqqlgccE/JGaRw==", + "version": "18.2.2", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-18.2.2.tgz", + "integrity": "sha512-jh/dGrY77HGm54HdTiQsxmvoRfFeJgHeWAK2+nWCPoc4b7OHcWxy/04cYffs0/27ThmABmppP7ERAyZ0f60uow==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -559,18 +557,18 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/core": "18.2.1" + "@angular/core": "18.2.2" } }, "node_modules/@angular/build": { - "version": "18.2.1", - "resolved": "https://registry.npmjs.org/@angular/build/-/build-18.2.1.tgz", - "integrity": "sha512-HwzjB+I31cAtjTTbbS2NbayzfcWthaKaofJlSmZIst3PN+GwLZ8DU0DRpd/xu5AXkk+DoAIWd+lzUIaqngz6ow==", + "version": "18.2.2", + "resolved": "https://registry.npmjs.org/@angular/build/-/build-18.2.2.tgz", + "integrity": "sha512-okaDdTMXnDhvnnnih6rPQnexL6htfEAPr19bB1Ci9d31gEjVuKZCjlcw2sPZ6BUyilwC9nZlCI5vbH1Ljf6mzA==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.1802.1", + "@angular-devkit/architect": "0.1802.2", "@babel/core": "7.25.2", "@babel/helper-annotate-as-pure": "7.24.7", "@babel/helper-split-export-declaration": "7.24.7", @@ -650,9 +648,9 @@ } }, "node_modules/@angular/cdk": { - "version": "18.2.1", - "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-18.2.1.tgz", - "integrity": "sha512-6y4MmpEPXze6igUHkLsBUPkxw32F8+rmW0xVXZchkSyGlFgqfh53ueXoryWb0qL4s5enkNY6AzXnKAqHfPNkVQ==", + "version": "18.2.2", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-18.2.2.tgz", + "integrity": "sha512-+u7ZcMA24WO03vDzlBJJWq+okZLFDeW9JrtHzrdiT09FDt4sdUp+7PddXaZcRHIXjJL+CaCLQ6slaqPNEufqgg==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -667,18 +665,18 @@ } }, "node_modules/@angular/cli": { - "version": "18.2.1", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-18.2.1.tgz", - "integrity": "sha512-SomUFDHanY4o7k3XBGf1eFt4z1h05IGJHfcbl2vxoc0lY59VN13m/pZsD2AtpqtJTzLQT02XQOUP4rmBbGoQ+Q==", + "version": "18.2.2", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-18.2.2.tgz", + "integrity": "sha512-HVVaMxnbID0q+V3KE+JqzGbPHcBUFo1RKhBZ/jxY7USZNzgtyYbRc0IYqPWNdr99UT5QefTJrjVazJo1nqQZvQ==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/architect": "0.1802.1", - "@angular-devkit/core": "18.2.1", - "@angular-devkit/schematics": "18.2.1", + "@angular-devkit/architect": "0.1802.2", + "@angular-devkit/core": "18.2.2", + "@angular-devkit/schematics": "18.2.2", "@inquirer/prompts": "5.3.8", "@listr2/prompt-adapter-inquirer": "2.0.15", - "@schematics/angular": "18.2.1", + "@schematics/angular": "18.2.2", "@yarnpkg/lockfile": "1.1.0", "ini": "4.1.3", "jsonc-parser": "3.3.1", @@ -701,9 +699,9 @@ } }, "node_modules/@angular/common": { - "version": "18.2.1", - "resolved": "https://registry.npmjs.org/@angular/common/-/common-18.2.1.tgz", - "integrity": "sha512-N0ZJO1/iU9UhprplZRPvBcdRgA/i6l6Ng5gXs5ymHBJ0lxsB+mDVCmC4jISjR9gAWc426xXwLaOpuP5Gv3f/yg==", + "version": "18.2.2", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-18.2.2.tgz", + "integrity": "sha512-AQe4xnnNNch/sXRnV82C8FmhijxPATKfPGojC2qbAG2o6VkWKgt5Lbj0O8WxvSIOS5Syedv+O2kLY/JMGWHNtw==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -712,14 +710,14 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/core": "18.2.1", + "@angular/core": "18.2.2", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/compiler": { - "version": "18.2.1", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-18.2.1.tgz", - "integrity": "sha512-5e9ygKEcsBoV6xpaGKVrtsLxLETlrM0oB7twl4qG/xuKYqCLj8cRQMcAKSqDfTPzWMOAQc7pHdk+uFVo/8dWHA==", + "version": "18.2.2", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-18.2.2.tgz", + "integrity": "sha512-gmVNCXZiv/CIk2eKRLnH19N9VsPuE2s3Oxm0MNi003zk1cLy7D4YEm4fSrjKXtPY8MMpRXiu5f63W94hLwWEVw==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -728,7 +726,7 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/core": "18.2.1" + "@angular/core": "18.2.2" }, "peerDependenciesMeta": { "@angular/core": { @@ -737,9 +735,9 @@ } }, "node_modules/@angular/compiler-cli": { - "version": "18.2.1", - "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-18.2.1.tgz", - "integrity": "sha512-D+Qba0r6RfHfffzrebGYp54h05AxpkagLjit/GczKNgWSP1gIgZxSfi88D+GvFmeWvZxWN1ecAQ+yqft9hJqWg==", + "version": "18.2.2", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-18.2.2.tgz", + "integrity": "sha512-fF7lDrTA12YGqVjF4LyMi4hm58cv9G6CWmzSlvun0nMYCwrbRNnakZsj19dOfiIqqu4MwHaF4w3PTmUSxkMuiw==", "license": "MIT", "dependencies": { "@babel/core": "7.25.2", @@ -760,14 +758,14 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/compiler": "18.2.1", + "@angular/compiler": "18.2.2", "typescript": ">=5.4 <5.6" } }, "node_modules/@angular/core": { - "version": "18.2.1", - "resolved": "https://registry.npmjs.org/@angular/core/-/core-18.2.1.tgz", - "integrity": "sha512-9KrSpJ65UlJZNXrE18NszcfOwb5LZgG+LYi5Doe7amt218R1bzb3trvuAm0ZzMaoKh4ugtUCkzEOd4FALPEX6w==", + "version": "18.2.2", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-18.2.2.tgz", + "integrity": "sha512-Rx6XajL0Ydj9hXUSPDvL2Q/kMzWtbiE3VxZFJnkE+fLQiWvr0GncB+NTb/nQ6QlPQ0ly60DvuI3KLcGDuFtGVA==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -781,9 +779,9 @@ } }, "node_modules/@angular/forms": { - "version": "18.2.1", - "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-18.2.1.tgz", - "integrity": "sha512-T7z8KUuj2PoPxrMrAruQVJha+x4a9Y6IrKYtArgOQQlTwCEJuqpVYuOk5l3fwWpHE9bVEjvgkAMI1D5YXA/U6w==", + "version": "18.2.2", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-18.2.2.tgz", + "integrity": "sha512-K8cv0w6o7+ocQfUrdSA3XaKrYfa1+2TlmtyxPHjEd2mCu2R+Yqo5RqJ3P8keFewJ1+bSLhz6xnn6mumwl0RnUQ==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -792,16 +790,16 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/common": "18.2.1", - "@angular/core": "18.2.1", - "@angular/platform-browser": "18.2.1", + "@angular/common": "18.2.2", + "@angular/core": "18.2.2", + "@angular/platform-browser": "18.2.2", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/language-service": { - "version": "18.2.1", - "resolved": "https://registry.npmjs.org/@angular/language-service/-/language-service-18.2.1.tgz", - "integrity": "sha512-JI4oox9ELNdDVg0uJqCwgyFoK4XrowV14wSoNpGhpTLModRg3eDS6q+8cKn27cjTQRZvpReyYSTfiZMB8j4eqQ==", + "version": "18.2.2", + "resolved": "https://registry.npmjs.org/@angular/language-service/-/language-service-18.2.2.tgz", + "integrity": "sha512-aROQNQeLf+o+F5OVvE/9BUe/Tpv8pjzmrZlogBbic5cb4IqSNhR4RjxbgIyXBO/6bhLCZwqfmMqRbW2J2xqMkg==", "dev": true, "license": "MIT", "engines": { @@ -809,9 +807,9 @@ } }, "node_modules/@angular/localize": { - "version": "18.2.1", - "resolved": "https://registry.npmjs.org/@angular/localize/-/localize-18.2.1.tgz", - "integrity": "sha512-nNdB6ehXCSBpQ75sTh6Gcwy2rgExfZEkGcPARJLpjqQlHO+Mk3b1y3ka6XT9M2qQYUeyukncTFUMEZWwHICsOA==", + "version": "18.2.2", + "resolved": "https://registry.npmjs.org/@angular/localize/-/localize-18.2.2.tgz", + "integrity": "sha512-grWQ3CVbizOWCthGpyIlNNnZCpF/xpWYa6tIsPzKOXLCyqFQ7vOEtSludNN1nsUmMlZQt76+wA17Fx0qcNx0EA==", "license": "MIT", "dependencies": { "@babel/core": "7.25.2", @@ -828,21 +826,21 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/compiler": "18.2.1", - "@angular/compiler-cli": "18.2.1" + "@angular/compiler": "18.2.2", + "@angular/compiler-cli": "18.2.2" } }, "node_modules/@angular/material": { - "version": "18.2.1", - "resolved": "https://registry.npmjs.org/@angular/material/-/material-18.2.1.tgz", - "integrity": "sha512-DBSJGqLttT9vYpLGWTuuRoOKd1mNelS0jnNo7jNZyMpjcGfuhNzmPtYiBkXfNsAl7YoXoUmX8+4uh1JZspQGqA==", + "version": "18.2.2", + "resolved": "https://registry.npmjs.org/@angular/material/-/material-18.2.2.tgz", + "integrity": "sha512-c+EQo1GEvM2w3qasgV/BGxB0bpJeSGs2WcMVTXCYVMcqEk8nwpALwfZiCAYl8JoKoiC5k993zz19xP2Eu14qkQ==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/animations": "^18.0.0 || ^19.0.0", - "@angular/cdk": "18.2.1", + "@angular/cdk": "18.2.2", "@angular/common": "^18.0.0 || ^19.0.0", "@angular/core": "^18.0.0 || ^19.0.0", "@angular/forms": "^18.0.0 || ^19.0.0", @@ -851,9 +849,9 @@ } }, "node_modules/@angular/platform-browser": { - "version": "18.2.1", - "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-18.2.1.tgz", - "integrity": "sha512-hQABX7QotGmCIR3EhCBCDh5ZTvQao+JkuK5CCw2G1PkRfJMBwEpjNqnyhz41hZhWiGlucp9jgbeypppW+mIQEw==", + "version": "18.2.2", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-18.2.2.tgz", + "integrity": "sha512-Bfvl8elCFxyJ9vlwamr4X5sVMcp/tSwBal2coyl0WR+/PH2PAAtf+/WMYxIN90yZmPiJx6RZWUSJRlHOFiFp3A==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -862,9 +860,9 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/animations": "18.2.1", - "@angular/common": "18.2.1", - "@angular/core": "18.2.1" + "@angular/animations": "18.2.2", + "@angular/common": "18.2.2", + "@angular/core": "18.2.2" }, "peerDependenciesMeta": { "@angular/animations": { @@ -873,9 +871,9 @@ } }, "node_modules/@angular/platform-browser-dynamic": { - "version": "18.2.1", - "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-18.2.1.tgz", - "integrity": "sha512-tYJHtshbaKrtnRA15k3vrveSVBqkVUGhINvGugFA2vMtdTOfhfPw+hhzYrcwJibgU49rHogCfI9mkIbpNRYntA==", + "version": "18.2.2", + "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-18.2.2.tgz", + "integrity": "sha512-UM/+1nY4iIj1v4lxAmV3XRHPAh/4qfNKScCLq8tJGot64rPCbtCl0Rl8rFFGqxAFvTErVDaJycUgWNZSfVl/hw==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -884,16 +882,16 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/common": "18.2.1", - "@angular/compiler": "18.2.1", - "@angular/core": "18.2.1", - "@angular/platform-browser": "18.2.1" + "@angular/common": "18.2.2", + "@angular/compiler": "18.2.2", + "@angular/core": "18.2.2", + "@angular/platform-browser": "18.2.2" } }, "node_modules/@angular/router": { - "version": "18.2.1", - "resolved": "https://registry.npmjs.org/@angular/router/-/router-18.2.1.tgz", - "integrity": "sha512-gVyqW6fYnG7oq1DlZSXJMQ2Py2dJQB7g6XVtRcYB1gR4aeowx5N9ws7PjqAi0ih91ASq2MmP4OlSSWLq+eaMGg==", + "version": "18.2.2", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-18.2.2.tgz", + "integrity": "sha512-tBHwuNtZNjzYAoVdveTI1ke/ZnQjKhc7gqDk9HCH2JUpdQhGbTvCKwDM51ktJpPMPcZlA263lQyy7VIyvdtK0A==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -902,16 +900,16 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/common": "18.2.1", - "@angular/core": "18.2.1", - "@angular/platform-browser": "18.2.1", + "@angular/common": "18.2.2", + "@angular/core": "18.2.2", + "@angular/platform-browser": "18.2.2", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/service-worker": { - "version": "18.2.1", - "resolved": "https://registry.npmjs.org/@angular/service-worker/-/service-worker-18.2.1.tgz", - "integrity": "sha512-Is4arGy+4HjyvALmR/GsWI4SwXYVJ1IkauAgxPsQKvWLNHdX7a/CEgEEVQGXq96H46QX9O2OcW69PnPatmJIXg==", + "version": "18.2.2", + "resolved": "https://registry.npmjs.org/@angular/service-worker/-/service-worker-18.2.2.tgz", + "integrity": "sha512-az0v0gNkAjOQ4DThDWfNJv2DkH63B4Vj/WnXd8pbY/C7Be6w3S1mN2y9vJClWAzUH/GSLQHnOrZJfnZtTc8M0w==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -923,8 +921,8 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/common": "18.2.1", - "@angular/core": "18.2.1" + "@angular/common": "18.2.2", + "@angular/core": "18.2.2" } }, "node_modules/@babel/code-frame": { @@ -1285,13 +1283,13 @@ } }, "node_modules/@babel/helpers": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.25.0.tgz", - "integrity": "sha512-MjgLZ42aCm0oGjJj8CtSM3DB8NOOf8h2l7DCTePJs29u+v7yO/RBX9nShlKMgFnRks/Q4tBAe7Hxnov9VkGwLw==", + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.25.6.tgz", + "integrity": "sha512-Xg0tn4HcfTijTwfDwYlvVCl43V6h4KyVVX2aEm4qdO/PC6L2YvzLHFdmxhoeSA3eslcE6+ZVXHgWwopXYLNq4Q==", "license": "MIT", "dependencies": { "@babel/template": "^7.25.0", - "@babel/types": "^7.25.0" + "@babel/types": "^7.25.6" }, "engines": { "node": ">=6.9.0" @@ -1313,12 +1311,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.25.4", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.4.tgz", - "integrity": "sha512-nq+eWrOgdtu3jG5Os4TQP3x3cLA8hR8TvJNjD8vnPa20WGycimcparWnLK4jJhElTK6SDyuJo1weMKO/5LpmLA==", + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.6.tgz", + "integrity": "sha512-trGdfBdbD0l1ZPmcJ83eNxB9rbEax4ALFTF7fN386TMYbeCQbyme5cOEXQhbGXKebwGaB/J52w1mrklMcbgy6Q==", "license": "MIT", "dependencies": { - "@babel/types": "^7.25.4" + "@babel/types": "^7.25.6" }, "bin": { "parser": "bin/babel-parser.js" @@ -1506,13 +1504,13 @@ } }, "node_modules/@babel/plugin-syntax-import-assertions": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.24.7.tgz", - "integrity": "sha512-Ec3NRUMoi8gskrkBe3fNmEQfxDvY8bgfQpz6jlk/41kX9eUjvpyqWU7PBP/pLAvMaSQjbMNKJmvX57jP+M6bPg==", + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.25.6.tgz", + "integrity": "sha512-aABl0jHw9bZ2karQ/uUD6XP4u0SG22SJrOHFoL6XB1R7dTovOP4TzTlsxOYC5yQ1pdscVK2JTUnF6QL3ARoAiQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-plugin-utils": "^7.24.8" }, "engines": { "node": ">=6.9.0" @@ -2723,16 +2721,16 @@ } }, "node_modules/@babel/traverse": { - "version": "7.25.4", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.4.tgz", - "integrity": "sha512-VJ4XsrD+nOvlXyLzmLzUs/0qjFS4sK30te5yEFlvbbUNEgKaVb2BHZUpAL+ttLPQAHNrsI3zZisbfha5Cvr8vg==", + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.6.tgz", + "integrity": "sha512-9Vrcx5ZW6UwK5tvqsj0nGpp/XzqthkT0dqIc9g1AdtygFToNtTF67XzYS//dm+SAK9cp3B9R4ZO/46p63SCjlQ==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.24.7", - "@babel/generator": "^7.25.4", - "@babel/parser": "^7.25.4", + "@babel/generator": "^7.25.6", + "@babel/parser": "^7.25.6", "@babel/template": "^7.25.0", - "@babel/types": "^7.25.4", + "@babel/types": "^7.25.6", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -2741,12 +2739,12 @@ } }, "node_modules/@babel/traverse/node_modules/@babel/generator": { - "version": "7.25.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.25.5.tgz", - "integrity": "sha512-abd43wyLfbWoxC6ahM8xTkqLpGB2iWBVyuKC9/srhFunCd1SDNrV1s72bBpK4hLj8KLzHBBcOblvLQZBNw9r3w==", + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.25.6.tgz", + "integrity": "sha512-VPC82gr1seXOpkjAAKoLhP50vx4vGNlF4msF64dSFq1P8RfB+QAuJWGHPXXPc8QyfVWwwB/TNNU4+ayZmHNbZw==", "license": "MIT", "dependencies": { - "@babel/types": "^7.25.4", + "@babel/types": "^7.25.6", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^2.5.1" @@ -2756,9 +2754,9 @@ } }, "node_modules/@babel/types": { - "version": "7.25.4", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.4.tgz", - "integrity": "sha512-zQ1ijeeCXVEh+aNL0RlmkPkG8HUiDcU2pzQQFjtbntgAczRASFzj4H+6+bV+dy1ntKR14I/DypeuRG1uma98iQ==", + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.6.tgz", + "integrity": "sha512-/l42B1qxpG6RdfYf343Uw1vmDjeNhneUXtzhojE7pDgfpEypmRhI6j1kr17XCVv4Cgl9HdAiQY2x0GwKm7rWCw==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.24.8", @@ -4096,6 +4094,16 @@ "node": ">=8" } }, + "node_modules/@jest/console/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@jest/console/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -4220,6 +4228,16 @@ "node": ">=8" } }, + "node_modules/@jest/core/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@jest/core/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -4417,6 +4435,16 @@ "node": ">=8" } }, + "node_modules/@jest/reporters/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@jest/reporters/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -4490,6 +4518,16 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@jest/test-sequencer/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@jest/transform": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", @@ -4587,6 +4625,16 @@ "node": ">=8" } }, + "node_modules/@jest/transform/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@jest/transform/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -5047,9 +5095,9 @@ ] }, "node_modules/@ng-bootstrap/ng-bootstrap": { - "version": "17.0.0", - "resolved": "https://registry.npmjs.org/@ng-bootstrap/ng-bootstrap/-/ng-bootstrap-17.0.0.tgz", - "integrity": "sha512-hTbBtozJlpevF1RO6J2adCoXiAkMTPV3wmXIyK05dVha4VsKjHibgaL6YldToKoh6ElQnIYkPEIJHX9z5EtyMw==", + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/@ng-bootstrap/ng-bootstrap/-/ng-bootstrap-17.0.1.tgz", + "integrity": "sha512-utbm8OXIoqVVYGVzQkOS773ymbjc+UMkXv8lyi7hTqLhCQs0rZ0yA74peqVZRuOGXLHgcSTA7fnJhA80iQOblw==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -5064,9 +5112,9 @@ } }, "node_modules/@ngtools/webpack": { - "version": "18.2.1", - "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-18.2.1.tgz", - "integrity": "sha512-v86U3jOoy5R9ZWe9Q0LbHRx/IBw1lbn0ldBU+gIIepREyVvb9CcH/vAyIb2Fw1zaYvvfG1OyzdrHyW8iGXjdnQ==", + "version": "18.2.2", + "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-18.2.2.tgz", + "integrity": "sha512-YhADmc+lVjLt3kze07A+yLry2yzcghdclu+7D3EDfa6fG2Pk33HK3MY2I0Z0BO+Ivoq7cV7yxm+naR+Od0Y5ng==", "dev": true, "license": "MIT", "engines": { @@ -5711,14 +5759,14 @@ ] }, "node_modules/@schematics/angular": { - "version": "18.2.1", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-18.2.1.tgz", - "integrity": "sha512-bBV7I+MCbdQmBPUFF4ECg37VReM0+AdQsxgwkjBBSYExmkErkDoDgKquwL/tH7stDCc5IfTd0g9BMeosRgDMug==", + "version": "18.2.2", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-18.2.2.tgz", + "integrity": "sha512-0uPA1kQ38RnbNrzMlveX/QAqQIDu2INl5IYd3EUbJZRfYSp1VVyOSyuIBJ+1iUl5Y5VUa2uylaVZXhFdKWprXw==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "18.2.1", - "@angular-devkit/schematics": "18.2.1", + "@angular-devkit/core": "18.2.2", + "@angular-devkit/schematics": "18.2.2", "jsonc-parser": "3.3.1" }, "engines": { @@ -5728,73 +5776,73 @@ } }, "node_modules/@sentry-internal/browser-utils": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-8.26.0.tgz", - "integrity": "sha512-O2Tj+WK33/ZVp5STnz6ZL0OO+/Idk2KqsH0ITQkQmyZ2z0kdzWOeqK7s7q3/My6rB1GfPcyqPcBBv4dVv92FYQ==", + "version": "8.27.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-8.27.0.tgz", + "integrity": "sha512-YTIwQ1GM1NTRXgN4DvpFSQ2x4pjlqQ0FQAyHW5x2ZYv4z7VmqG4Xkid1P/srQUipECk6nxkebfD4WR19nLsvnQ==", "license": "MIT", "dependencies": { - "@sentry/core": "8.26.0", - "@sentry/types": "8.26.0", - "@sentry/utils": "8.26.0" + "@sentry/core": "8.27.0", + "@sentry/types": "8.27.0", + "@sentry/utils": "8.27.0" }, "engines": { "node": ">=14.18" } }, "node_modules/@sentry-internal/feedback": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-8.26.0.tgz", - "integrity": "sha512-hQtw1gg8n6ERK1UH47F7ZI1zOsbhu0J2VX+TrnkpaQR2FgxDW1oe9Ja6oCV4CQKuR4w+1ZI/Kj4imSt0K33kEw==", + "version": "8.27.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-8.27.0.tgz", + "integrity": "sha512-b71PQc9aK1X9b/SO1DiJlrnAEx4n0MzPZQ/tKd9oRWDyGit6pJWZfQns9r2rvc96kJPMOTxFAa/upXRCkA723A==", "license": "MIT", "dependencies": { - "@sentry/core": "8.26.0", - "@sentry/types": "8.26.0", - "@sentry/utils": "8.26.0" + "@sentry/core": "8.27.0", + "@sentry/types": "8.27.0", + "@sentry/utils": "8.27.0" }, "engines": { "node": ">=14.18" } }, "node_modules/@sentry-internal/replay": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-8.26.0.tgz", - "integrity": "sha512-JDY7W2bswlp5c3483lKP4kcb75fHNwGNfwD8x8FsY9xMjv7nxeXjLpR5cCEk1XqPq2+n6w4j7mJOXhEXGiUIKg==", + "version": "8.27.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-8.27.0.tgz", + "integrity": "sha512-Ofucncaon98dvlxte2L//hwuG9yILSxNrTz/PmO0k+HzB9q+oBic4667QF+azWR2qv4oKSWpc+vEovP3hVqveA==", "license": "MIT", "dependencies": { - "@sentry-internal/browser-utils": "8.26.0", - "@sentry/core": "8.26.0", - "@sentry/types": "8.26.0", - "@sentry/utils": "8.26.0" + "@sentry-internal/browser-utils": "8.27.0", + "@sentry/core": "8.27.0", + "@sentry/types": "8.27.0", + "@sentry/utils": "8.27.0" }, "engines": { "node": ">=14.18" } }, "node_modules/@sentry-internal/replay-canvas": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-8.26.0.tgz", - "integrity": "sha512-2CFQW6f9aJHIo/DqmqYa9PaYoLn1o36ywc0h8oyGrD4oPCbrnE5F++PmTdc71GBODu41HBn/yoCTLmxOD+UjpA==", + "version": "8.27.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-8.27.0.tgz", + "integrity": "sha512-uuEfiWbjwugB9M4KxXxovHYiKRqg/R6U4EF8xM/Ub4laUuEcWsfRp7lQ3MxL3qYojbca8ncIFic2bIoKMPeejA==", "license": "MIT", "dependencies": { - "@sentry-internal/replay": "8.26.0", - "@sentry/core": "8.26.0", - "@sentry/types": "8.26.0", - "@sentry/utils": "8.26.0" + "@sentry-internal/replay": "8.27.0", + "@sentry/core": "8.27.0", + "@sentry/types": "8.27.0", + "@sentry/utils": "8.27.0" }, "engines": { "node": ">=14.18" } }, "node_modules/@sentry/angular": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@sentry/angular/-/angular-8.26.0.tgz", - "integrity": "sha512-9YolcJMdEzS6hbImal3jrAbzGZGM7DpmfSOfzt1Cs4bYTD9gCxKRkLyUgiNRIlrIBO7CkdpMrCSY+nEohvCw7A==", + "version": "8.27.0", + "resolved": "https://registry.npmjs.org/@sentry/angular/-/angular-8.27.0.tgz", + "integrity": "sha512-0BjjrqnVMofVbQGEwfZgYAZWFl4ewkWRjcUj+NIX4iJpRZZniKZxo6XOlo/pTkt4oVHsbNHJO0C1tS+gRZFErg==", "license": "MIT", "dependencies": { - "@sentry/browser": "8.26.0", - "@sentry/core": "8.26.0", - "@sentry/types": "8.26.0", - "@sentry/utils": "8.26.0", + "@sentry/browser": "8.27.0", + "@sentry/core": "8.27.0", + "@sentry/types": "8.27.0", + "@sentry/utils": "8.27.0", "tslib": "^2.4.1" }, "engines": { @@ -5808,52 +5856,52 @@ } }, "node_modules/@sentry/browser": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-8.26.0.tgz", - "integrity": "sha512-e5s6eKlwLZWzTwQcBwqyAGZMMuQROW9Z677VzwkSyREWAIkKjfH2VBxHATnNGc0IVkNHjD7iH3ixo3C0rLKM3w==", + "version": "8.27.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-8.27.0.tgz", + "integrity": "sha512-eL1eaHwoYUGkp4mpeYesH6WtCrm+0u9jYCW5Lm0MAeTmpx22BZKEmj0OljuUJXGnJwFbvPDlRjyz6QG11m8kZA==", "license": "MIT", "dependencies": { - "@sentry-internal/browser-utils": "8.26.0", - "@sentry-internal/feedback": "8.26.0", - "@sentry-internal/replay": "8.26.0", - "@sentry-internal/replay-canvas": "8.26.0", - "@sentry/core": "8.26.0", - "@sentry/types": "8.26.0", - "@sentry/utils": "8.26.0" + "@sentry-internal/browser-utils": "8.27.0", + "@sentry-internal/feedback": "8.27.0", + "@sentry-internal/replay": "8.27.0", + "@sentry-internal/replay-canvas": "8.27.0", + "@sentry/core": "8.27.0", + "@sentry/types": "8.27.0", + "@sentry/utils": "8.27.0" }, "engines": { "node": ">=14.18" } }, "node_modules/@sentry/core": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-8.26.0.tgz", - "integrity": "sha512-g/tVmTZD4GNbLFf++hKJfBpcCAtduFEMLnbfa9iT/QEZjlmP+EzY+GsH9bafM5VsNe8DiOUp+kJKWtShzlVdBA==", + "version": "8.27.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-8.27.0.tgz", + "integrity": "sha512-4frlXluHT3Du+Omw91K04jpvbfMtydvg4Bxj2+gt/DT19Swhm/fbEpzdUjgbAd3Jinj/n0qk/jFRXjr9JZKFjg==", "license": "MIT", "dependencies": { - "@sentry/types": "8.26.0", - "@sentry/utils": "8.26.0" + "@sentry/types": "8.27.0", + "@sentry/utils": "8.27.0" }, "engines": { "node": ">=14.18" } }, "node_modules/@sentry/types": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@sentry/types/-/types-8.26.0.tgz", - "integrity": "sha512-zKmh6SWsJh630rpt7a9vP4Cm4m1C2gDTUqUiH565CajCL/4cePpNWYrNwalSqsOSL7B9OrczA1+n6a6XvND+ng==", + "version": "8.27.0", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-8.27.0.tgz", + "integrity": "sha512-B6lrP46+m2x0lfqWc9F4VcUbN893mVGnPEd7KIMRk95mPzkFJ3sNxggTQF5/ZfNO7lDQYQb22uysB5sj/BqFiw==", "license": "MIT", "engines": { "node": ">=14.18" } }, "node_modules/@sentry/utils": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-8.26.0.tgz", - "integrity": "sha512-xvlPU9Hd2BlyT+FhWHGNwnxWqdVRk2AHnDtVcW4Ma0Ri5EwS+uy4Jeik5UkSv8C5RVb9VlxFmS8LN3I1MPJsLw==", + "version": "8.27.0", + "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-8.27.0.tgz", + "integrity": "sha512-gyJM3SyLQe0A3mkQVVNdKYvk3ZoikkYgyA/D+5StFNLKdyUgEbJgXOGXrQSSYPF7BSX6Sc5b0KHCglPII0KuKw==", "license": "MIT", "dependencies": { - "@sentry/types": "8.26.0" + "@sentry/types": "8.27.0" }, "engines": { "node": ">=14.18" @@ -6273,27 +6321,18 @@ } }, "node_modules/@types/eslint": { - "version": "9.6.0", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.0.tgz", - "integrity": "sha512-gi6WQJ7cHRgZxtkQEoyHMppPjq9Kxo5Tjn2prSKDSmZrCz8TZ3jSRCeTJm+WoM+oB0WG37bRqLzaaU3q7JypGg==", + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, - "node_modules/@types/eslint-scope": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", - "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, "node_modules/@types/estree": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", @@ -6456,9 +6495,9 @@ } }, "node_modules/@types/node": { - "version": "22.5.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.0.tgz", - "integrity": "sha512-DkFrJOe+rfdHTqqMg0bSNlGlQ85hSoh2TPzZyhHsXnMtligRWpxUySiyw8FY14ITt24HVCiQPWxS3KO/QlGmWg==", + "version": "22.5.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.1.tgz", + "integrity": "sha512-KkHsxej0j9IW1KKOOAA/XBA0z08UFSrRQHErzEfA3Vgq57eXIMYboIlHJuYIfd+lwCQjtKqUu3UnmKbtUc9yRw==", "dev": true, "license": "MIT", "dependencies": { @@ -6662,17 +6701,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.2.0.tgz", - "integrity": "sha512-02tJIs655em7fvt9gps/+4k4OsKULYGtLBPJfOsmOq1+3cdClYiF0+d6mHu6qDnTcg88wJBkcPLpQhq7FyDz0A==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.3.0.tgz", + "integrity": "sha512-FLAIn63G5KH+adZosDYiutqkOkYEx0nvcwNNfJAf+c7Ae/H35qWwTYvPZUKFj5AS+WfHG/WJJfWnDnyNUlp8UA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.2.0", - "@typescript-eslint/type-utils": "8.2.0", - "@typescript-eslint/utils": "8.2.0", - "@typescript-eslint/visitor-keys": "8.2.0", + "@typescript-eslint/scope-manager": "8.3.0", + "@typescript-eslint/type-utils": "8.3.0", + "@typescript-eslint/utils": "8.3.0", + "@typescript-eslint/visitor-keys": "8.3.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -6696,16 +6735,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.2.0.tgz", - "integrity": "sha512-j3Di+o0lHgPrb7FxL3fdEy6LJ/j2NE8u+AP/5cQ9SKb+JLH6V6UHDqJ+e0hXBkHP1wn1YDFjYCS9LBQsZDlDEg==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.3.0.tgz", + "integrity": "sha512-h53RhVyLu6AtpUzVCYLPhZGL5jzTD9fZL+SYf/+hYOx2bDkyQXztXSc4tbvKYHzfMXExMLiL9CWqJmVz6+78IQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/scope-manager": "8.2.0", - "@typescript-eslint/types": "8.2.0", - "@typescript-eslint/typescript-estree": "8.2.0", - "@typescript-eslint/visitor-keys": "8.2.0", + "@typescript-eslint/scope-manager": "8.3.0", + "@typescript-eslint/types": "8.3.0", + "@typescript-eslint/typescript-estree": "8.3.0", + "@typescript-eslint/visitor-keys": "8.3.0", "debug": "^4.3.4" }, "engines": { @@ -6725,14 +6764,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.2.0.tgz", - "integrity": "sha512-OFn80B38yD6WwpoHU2Tz/fTz7CgFqInllBoC3WP+/jLbTb4gGPTy9HBSTsbDWkMdN55XlVU0mMDYAtgvlUspGw==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.3.0.tgz", + "integrity": "sha512-mz2X8WcN2nVu5Hodku+IR8GgCOl4C0G/Z1ruaWN4dgec64kDBabuXyPAr+/RgJtumv8EEkqIzf3X2U5DUKB2eg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.2.0", - "@typescript-eslint/visitor-keys": "8.2.0" + "@typescript-eslint/types": "8.3.0", + "@typescript-eslint/visitor-keys": "8.3.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6743,14 +6782,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.2.0.tgz", - "integrity": "sha512-g1CfXGFMQdT5S+0PSO0fvGXUaiSkl73U1n9LTK5aRAFnPlJ8dLKkXr4AaLFvPedW8lVDoMgLLE3JN98ZZfsj0w==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.3.0.tgz", + "integrity": "sha512-wrV6qh//nLbfXZQoj32EXKmwHf4b7L+xXLrP3FZ0GOUU72gSvLjeWUl5J5Ue5IwRxIV1TfF73j/eaBapxx99Lg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.2.0", - "@typescript-eslint/utils": "8.2.0", + "@typescript-eslint/typescript-estree": "8.3.0", + "@typescript-eslint/utils": "8.3.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -6768,9 +6807,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.2.0.tgz", - "integrity": "sha512-6a9QSK396YqmiBKPkJtxsgZZZVjYQ6wQ/TlI0C65z7vInaETuC6HAHD98AGLC8DyIPqHytvNuS8bBVvNLKyqvQ==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.3.0.tgz", + "integrity": "sha512-y6sSEeK+facMaAyixM36dQ5NVXTnKWunfD1Ft4xraYqxP0lC0POJmIaL/mw72CUMqjY9qfyVfXafMeaUj0noWw==", "dev": true, "license": "MIT", "engines": { @@ -6782,16 +6821,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.2.0.tgz", - "integrity": "sha512-kiG4EDUT4dImplOsbh47B1QnNmXSoUqOjWDvCJw/o8LgfD0yr7k2uy54D5Wm0j4t71Ge1NkynGhpWdS0dEIAUA==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.3.0.tgz", + "integrity": "sha512-Mq7FTHl0R36EmWlCJWojIC1qn/ZWo2YiWYc1XVtasJ7FIgjo0MVv9rZWXEE7IK2CGrtwe1dVOxWwqXUdNgfRCA==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/types": "8.2.0", - "@typescript-eslint/visitor-keys": "8.2.0", + "@typescript-eslint/types": "8.3.0", + "@typescript-eslint/visitor-keys": "8.3.0", "debug": "^4.3.4", - "globby": "^11.1.0", + "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", @@ -6811,16 +6850,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.2.0.tgz", - "integrity": "sha512-O46eaYKDlV3TvAVDNcoDzd5N550ckSe8G4phko++OCSC1dYIb9LTc3HDGYdWqWIAT5qDUKphO6sd9RrpIJJPfg==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.3.0.tgz", + "integrity": "sha512-F77WwqxIi/qGkIGOGXNBLV7nykwfjLsdauRB/DOFPdv6LTF3BHHkBpq81/b5iMPSF055oO2BiivDJV4ChvNtXA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.2.0", - "@typescript-eslint/types": "8.2.0", - "@typescript-eslint/typescript-estree": "8.2.0" + "@typescript-eslint/scope-manager": "8.3.0", + "@typescript-eslint/types": "8.3.0", + "@typescript-eslint/typescript-estree": "8.3.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6834,13 +6873,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.2.0.tgz", - "integrity": "sha512-sbgsPMW9yLvS7IhCi8IpuK1oBmtbWUNP+hBdwl/I9nzqVsszGnNGti5r9dUtF5RLivHUFFIdRvLiTsPhzSyJ3Q==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.3.0.tgz", + "integrity": "sha512-RmZwrTbQ9QveF15m/Cl28n0LXD6ea2CjkhH5rQ55ewz3H24w+AMCJHPVYaZ8/0HoG8Z3cLLFFycRXxeO2tz9FA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.2.0", + "@typescript-eslint/types": "8.3.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -7076,12 +7115,6 @@ "node": ">= 0.6" } }, - "node_modules/ace-builds": { - "version": "1.36.0", - "resolved": "https://registry.npmjs.org/ace-builds/-/ace-builds-1.36.0.tgz", - "integrity": "sha512-7to4F86V5N13EY4M9LWaGo2Wmr9iWe5CrYpc28F+/OyYCf7yd+xBV5x9v/GB73EBGGoYd89m6JjeIUjkL6Yw+w==", - "license": "BSD-3-Clause" - }, "node_modules/acorn": { "version": "8.12.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", @@ -7505,6 +7538,16 @@ "node": ">=8" } }, + "node_modules/babel-jest/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/babel-jest/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -7856,12 +7899,6 @@ "@popperjs/core": "^2.11.8" } }, - "node_modules/brace": { - "version": "0.11.1", - "resolved": "https://registry.npmjs.org/brace/-/brace-0.11.1.tgz", - "integrity": "sha512-Fc8Ne62jJlKHiG/ajlonC4Sd66Pq68fFwK4ihJGNZpGqboc324SQk+lRvMzpPRuJOmfrJefdG8/7JdWX4bzJ2Q==", - "license": "MIT" - }, "node_modules/brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", @@ -8099,9 +8136,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001651", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001651.tgz", - "integrity": "sha512-9Cf+Xv1jJNe1xPZLGuUXLNkE1BoDkqRqYyFJ9TDYSqhduqA4hu4oR9HluGoWYQC/aj8WHjsGVV+bwkh0+tegRg==", + "version": "1.0.30001654", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001654.tgz", + "integrity": "sha512-wLJc602fW0OdrUR+PqsBUH3dgrjDcT+mWs/Kw86zPvgjiqOiI2TXMkBFK4KihYzZclmJxrFwgYhZDSEogFai/g==", "funding": [ { "type": "opencollective", @@ -8210,9 +8247,9 @@ } }, "node_modules/cjs-module-lexer": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.3.1.tgz", - "integrity": "sha512-a3KdPAANPbNE4ZUv9h6LckSl9zLsYOP4MBmhIPkRaeyybt+r4UghLvq+xw/YwUcC1gqylCkL4rdVs3Lwupjm4Q==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.0.tgz", + "integrity": "sha512-N1NGmowPlGBLsOZLPvm48StN04V4YvQRL0i6b7ctrVY3epjP/ct7hFLOItz6pDIvRjwpfPxi52a2UWV2ziir8g==", "dev": true, "license": "MIT" }, @@ -8653,53 +8690,6 @@ "node": ">=10.13.0" } }, - "node_modules/copy-webpack-plugin/node_modules/globby": { - "version": "14.0.2", - "resolved": "https://registry.npmjs.org/globby/-/globby-14.0.2.tgz", - "integrity": "sha512-s3Fq41ZVh7vbbe2PN3nrW7yC7U7MFVc5c98/iTl9c2GawNMKx/J648KQRW6WKkuU8GIbbh2IXfIRQjOZnXcTnw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sindresorhus/merge-streams": "^2.1.0", - "fast-glob": "^3.3.2", - "ignore": "^5.2.4", - "path-type": "^5.0.0", - "slash": "^5.1.0", - "unicorn-magic": "^0.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/copy-webpack-plugin/node_modules/path-type": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-5.0.0.tgz", - "integrity": "sha512-5HviZNaZcfqP95rwpv+1HDgUamezbqdSYTyzjTvwtJSnIH+3vnbmWsItli8OFEndS984VT55M3jduxZbX351gg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/copy-webpack-plugin/node_modules/slash": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", - "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/core-js": { "version": "3.38.1", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.38.1.tgz", @@ -9663,6 +9653,16 @@ "node": ">=8" } }, + "node_modules/dir-glob/node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/dns-packet": { "version": "5.6.1", "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", @@ -9791,9 +9791,9 @@ } }, "node_modules/emoji-regex": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.3.0.tgz", - "integrity": "sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", "dev": true, "license": "MIT" }, @@ -10007,9 +10007,9 @@ } }, "node_modules/escalade": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", - "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "license": "MIT", "engines": { "node": ">=6" @@ -10222,10 +10222,41 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/eslint-plugin-deprecation/node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-plugin-deprecation/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/eslint-plugin-jest": { - "version": "28.8.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-28.8.0.tgz", - "integrity": "sha512-Tubj1hooFxCl52G4qQu0edzV/+EZzPUeN8p2NnW5uu4fbDs+Yo7+qDVDc4/oG3FbCqEBmu/OC3LSsyiU22oghw==", + "version": "28.8.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-28.8.1.tgz", + "integrity": "sha512-G46XMyYu6PtSNJUkQ0hsPjzXYpzq/O4vpCciMizTKRJG8kNsRreGoMRDG6H9FIB/xVgfFuclVnuX4XRvFUzrZQ==", "dev": true, "license": "MIT", "dependencies": { @@ -10393,6 +10424,37 @@ "node": ">=4.0" } }, + "node_modules/eslint-plugin-jest-extended/node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-plugin-jest-extended/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/eslint-plugin-prettier": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.1.tgz", @@ -11462,21 +11524,21 @@ } }, "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/globby/-/globby-14.0.2.tgz", + "integrity": "sha512-s3Fq41ZVh7vbbe2PN3nrW7yC7U7MFVc5c98/iTl9c2GawNMKx/J648KQRW6WKkuU8GIbbh2IXfIRQjOZnXcTnw==", "dev": true, "license": "MIT", "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" + "@sindresorhus/merge-streams": "^2.1.0", + "fast-glob": "^3.3.2", + "ignore": "^5.2.4", + "path-type": "^5.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.1.0" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -12826,6 +12888,16 @@ "node": ">=8" } }, + "node_modules/jest-circus/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/jest-circus/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -13058,6 +13130,16 @@ "node": ">=8" } }, + "node_modules/jest-config/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/jest-config/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -13603,6 +13685,16 @@ "node": ">=8" } }, + "node_modules/jest-message-util/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/jest-message-util/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -13786,6 +13878,16 @@ "node": ">=8" } }, + "node_modules/jest-resolve/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/jest-resolve/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -14026,6 +14128,16 @@ "node": ">=8" } }, + "node_modules/jest-runtime/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/jest-runtime/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -16137,9 +16249,9 @@ "license": "MIT" }, "node_modules/ng-mocks": { - "version": "14.13.0", - "resolved": "https://registry.npmjs.org/ng-mocks/-/ng-mocks-14.13.0.tgz", - "integrity": "sha512-cQ6nUj/P+v7X52gYU6bAj/03iDKl2pzbPy2V0tx/d5lxME063Vxc190p6UPlJkbRIxcB+OqSALPgQvy83efzjw==", + "version": "14.13.1", + "resolved": "https://registry.npmjs.org/ng-mocks/-/ng-mocks-14.13.1.tgz", + "integrity": "sha512-eyfnjXeC108SqVD09i/cBwCpKkK0JjBoAg8jp7oQS2HS081K3WJTttFpgLGeLDYKmZsZ6nYpI+HHNQ3OksaJ7A==", "dev": true, "license": "MIT", "funding": { @@ -16245,9 +16357,9 @@ } }, "node_modules/node-gyp-build": { - "version": "4.8.1", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.1.tgz", - "integrity": "sha512-OSs33Z9yWr148JZcbZd5WiAXhh/n9z8TxQcdMhIOlpN9AhWpLfvVFO73+m77bBABQMaY9XSvIa+qk0jlI7Gcaw==", + "version": "4.8.2", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.2.tgz", + "integrity": "sha512-IRUxE4BVsHWXkV/SFOut4qTlagw2aM8T5/vnTsmrHJvVoKueJHRc/JaFND7QDDc61kLYUJ6qlZM3sqTSyx2dTw==", "dev": true, "license": "MIT", "bin": { @@ -17087,13 +17199,16 @@ "license": "MIT" }, "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-5.0.0.tgz", + "integrity": "sha512-5HviZNaZcfqP95rwpv+1HDgUamezbqdSYTyzjTvwtJSnIH+3vnbmWsItli8OFEndS984VT55M3jduxZbX351gg==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/pepjs": { @@ -17421,9 +17536,9 @@ "license": "MIT" }, "node_modules/posthog-js": { - "version": "1.157.2", - "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.157.2.tgz", - "integrity": "sha512-ATYKGs+Q51u26nHHhrhWNh1whqFm7j/rwQQYw+y6/YzNmRlo+YsqrGZji9nqXb9/4fo0ModDr+ZmuOI3hKkUXA==", + "version": "1.160.0", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.160.0.tgz", + "integrity": "sha512-K/RRgmPYIpP69nnveCJfkclb8VU+R+jsgqlrKaLGsM5CtQM9g01WOzAiT3u36WLswi58JiFMXgJtECKQuoqTgQ==", "license": "MIT", "dependencies": { "fflate": "^0.4.8", @@ -18822,12 +18937,12 @@ } }, "node_modules/simple-statistics": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/simple-statistics/-/simple-statistics-7.8.4.tgz", - "integrity": "sha512-KHC7X+4Dji2rFgnPU7FxPPp4GxPz9hvQCHx2x6JbjLYNKuSMHcoNZ54gF0xBBMOAvNtWmfCHcfC4MD2T89ffEA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/simple-statistics/-/simple-statistics-7.8.5.tgz", + "integrity": "sha512-yw4aOnkvPLbL80zamrEKznAnk5cIIkjEcx/z0aQl+m/YKMmVufrnWgWJWRspqZtwh+ElZXRhJ0MtnUjFUQV5Ow==", "license": "ISC", "engines": { - "node": ">= 18" + "node": "*" } }, "node_modules/sisteransi": { @@ -18838,13 +18953,16 @@ "license": "MIT" }, "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/slice-ansi": { @@ -21032,13 +21150,12 @@ } }, "node_modules/webpack": { - "version": "5.93.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.93.0.tgz", - "integrity": "sha512-Y0m5oEY1LRuwly578VqluorkXbvXKh7U3rLoQCEO04M97ScRr44afGVkI0FQFsXzysk5OgFAxjZAb9rsGQVihA==", + "version": "5.94.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.94.0.tgz", + "integrity": "sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==", "dev": true, "license": "MIT", "dependencies": { - "@types/eslint-scope": "^3.7.3", "@types/estree": "^1.0.5", "@webassemblyjs/ast": "^1.12.1", "@webassemblyjs/wasm-edit": "^1.12.1", @@ -21047,7 +21164,7 @@ "acorn-import-attributes": "^1.9.5", "browserslist": "^4.21.10", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.0", + "enhanced-resolve": "^5.17.1", "es-module-lexer": "^1.2.1", "eslint-scope": "5.1.1", "events": "^3.2.0", diff --git a/package.json b/package.json index da3d4a60a893..0fba06f2db55 100644 --- a/package.json +++ b/package.json @@ -13,18 +13,18 @@ "node_modules" ], "dependencies": { - "@angular/animations": "18.2.1", - "@angular/cdk": "18.2.1", - "@angular/common": "18.2.1", - "@angular/compiler": "18.2.1", - "@angular/core": "18.2.1", - "@angular/forms": "18.2.1", - "@angular/localize": "18.2.1", - "@angular/material": "18.2.1", - "@angular/platform-browser": "18.2.1", - "@angular/platform-browser-dynamic": "18.2.1", - "@angular/router": "18.2.1", - "@angular/service-worker": "18.2.1", + "@angular/animations": "18.2.2", + "@angular/cdk": "18.2.2", + "@angular/common": "18.2.2", + "@angular/compiler": "18.2.2", + "@angular/core": "18.2.2", + "@angular/forms": "18.2.2", + "@angular/localize": "18.2.2", + "@angular/material": "18.2.2", + "@angular/platform-browser": "18.2.2", + "@angular/platform-browser-dynamic": "18.2.2", + "@angular/router": "18.2.2", + "@angular/service-worker": "18.2.2", "@ctrl/ngx-emoji-mart": "9.2.0", "@danielmoncada/angular-datetime-picker": "18.1.0", "@fingerprintjs/fingerprintjs": "4.4.3", @@ -34,16 +34,14 @@ "@fortawesome/free-regular-svg-icons": "6.6.0", "@fortawesome/free-solid-svg-icons": "6.6.0", "@ls1intum/apollon": "3.3.14", - "@ng-bootstrap/ng-bootstrap": "17.0.0", + "@ng-bootstrap/ng-bootstrap": "17.0.1", "@ngx-translate/core": "15.0.0", "@ngx-translate/http-loader": "8.0.0", - "@sentry/angular": "8.26.0", + "@sentry/angular": "8.27.0", "@swimlane/ngx-charts": "20.5.0", "@swimlane/ngx-graph": "8.4.0", "@vscode/codicons": "0.0.36", - "ace-builds": "1.36.0", "bootstrap": "5.3.3", - "brace": "0.11.1", "compare-versions": "6.1.1", "core-js": "3.38.1", "crypto-js": "4.2.0", @@ -64,12 +62,12 @@ "ngx-infinite-scroll": "18.0.0", "ngx-webstorage": "18.0.0", "papaparse": "5.4.1", - "posthog-js": "1.157.2", + "posthog-js": "1.160.0", "rxjs": "7.8.1", "showdown": "2.1.0", "showdown-highlight": "3.1.0", "showdown-katex": "0.6.0", - "simple-statistics": "7.8.4", + "simple-statistics": "7.8.5", "smoothscroll-polyfill": "0.4.4", "sockjs-client": "1.6.1", "split.js": "1.6.5", @@ -115,33 +113,33 @@ }, "devDependencies": { "@angular-builders/jest": "18.0.0", - "@angular-devkit/build-angular": "18.2.1", + "@angular-devkit/build-angular": "18.2.2", "@angular-eslint/builder": "18.3.0", "@angular-eslint/eslint-plugin": "18.3.0", "@angular-eslint/eslint-plugin-template": "18.3.0", "@angular-eslint/schematics": "18.3.0", "@angular-eslint/template-parser": "18.3.0", - "@angular/cli": "18.2.1", - "@angular/compiler-cli": "18.2.1", - "@angular/language-service": "18.2.1", - "@sentry/types": "8.26.0", + "@angular/cli": "18.2.2", + "@angular/compiler-cli": "18.2.2", + "@angular/language-service": "18.2.2", + "@sentry/types": "8.27.0", "@types/crypto-js": "4.2.2", "@types/d3-shape": "3.1.6", "@types/dompurify": "3.0.5", "@types/jest": "29.5.12", "@types/lodash-es": "4.17.12", - "@types/node": "22.5.0", + "@types/node": "22.5.1", "@types/papaparse": "5.3.14", "@types/showdown": "2.0.6", "@types/smoothscroll-polyfill": "0.3.4", "@types/sockjs-client": "1.5.4", "@types/uuid": "10.0.0", - "@typescript-eslint/eslint-plugin": "8.2.0", - "@typescript-eslint/parser": "8.2.0", + "@typescript-eslint/eslint-plugin": "8.3.0", + "@typescript-eslint/parser": "8.3.0", "eslint": "9.9.1", "eslint-config-prettier": "9.1.0", "eslint-plugin-deprecation": "3.0.0", - "eslint-plugin-jest": "28.8.0", + "eslint-plugin-jest": "28.8.1", "eslint-plugin-jest-extended": "2.4.0", "eslint-plugin-prettier": "5.2.1", "folder-hash": "4.0.4", @@ -154,7 +152,7 @@ "jest-junit": "16.0.0", "jest-preset-angular": "14.2.2", "lint-staged": "15.2.9", - "ng-mocks": "14.13.0", + "ng-mocks": "14.13.1", "prettier": "3.3.3", "sass": "1.77.8", "ts-jest": "29.2.5", diff --git a/src/main/java/de/tum/in/www1/artemis/config/connector/GitLabApiConfiguration.java b/src/main/java/de/tum/in/www1/artemis/config/connector/GitLabApiConfiguration.java index 45bfdaf4a97b..f88d93656d1b 100644 --- a/src/main/java/de/tum/in/www1/artemis/config/connector/GitLabApiConfiguration.java +++ b/src/main/java/de/tum/in/www1/artemis/config/connector/GitLabApiConfiguration.java @@ -8,6 +8,9 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; +// Gitlab support will be removed in 8.0.0. Please migrate to LocalVC using e.g. the PR https://github.com/ls1intum/Artemis/pull/8972 +@Deprecated(since = "7.5.0", forRemoval = true) + @Configuration @Profile("gitlab | gitlabci") public class GitLabApiConfiguration { diff --git a/src/main/java/de/tum/in/www1/artemis/domain/enumeration/ProgrammingLanguage.java b/src/main/java/de/tum/in/www1/artemis/domain/enumeration/ProgrammingLanguage.java index 3d43ae71fc93..60da52e071f2 100644 --- a/src/main/java/de/tum/in/www1/artemis/domain/enumeration/ProgrammingLanguage.java +++ b/src/main/java/de/tum/in/www1/artemis/domain/enumeration/ProgrammingLanguage.java @@ -47,7 +47,8 @@ public enum ProgrammingLanguage { VHDL, ASSEMBLER, SWIFT, - OCAML + OCAML, + RUST ); // @formatter:on diff --git a/src/main/java/de/tum/in/www1/artemis/domain/iris/session/IrisCompetencyGenerationSession.java b/src/main/java/de/tum/in/www1/artemis/domain/iris/session/IrisCompetencyGenerationSession.java deleted file mode 100644 index 4353f30a63c1..000000000000 --- a/src/main/java/de/tum/in/www1/artemis/domain/iris/session/IrisCompetencyGenerationSession.java +++ /dev/null @@ -1,51 +0,0 @@ -package de.tum.in.www1.artemis.domain.iris.session; - -import jakarta.persistence.DiscriminatorValue; -import jakarta.persistence.Entity; -import jakarta.persistence.ManyToOne; - -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.fasterxml.jackson.annotation.JsonInclude; - -import de.tum.in.www1.artemis.domain.Course; -import de.tum.in.www1.artemis.domain.User; - -/** - * A IrisCompetencyGenerationSession is a session specific to a course and user. - * This is used for course editors to generate competency recommendations. - */ -@Entity -@DiscriminatorValue("COMPETENCY_GENERATION") -@JsonInclude(JsonInclude.Include.NON_EMPTY) -public class IrisCompetencyGenerationSession extends IrisSession { - - @ManyToOne - @JsonIgnore - private Course course; - - @ManyToOne - @JsonIgnore - private User user; - - public Course getCourse() { - return course; - } - - public void setCourse(Course course) { - this.course = course; - } - - public User getUser() { - return user; - } - - public void setUser(User user) { - this.user = user; - } - - @Override - public String toString() { - return "IrisCompetencyGenerationSession{" + "id=" + getId() + ", course=" + (course == null ? "null" : course.getId()) + ", user=" - + (user == null ? "null" : user.getName()) + '}'; - } -} diff --git a/src/main/java/de/tum/in/www1/artemis/domain/lti/Lti13ClientRegistration.java b/src/main/java/de/tum/in/www1/artemis/domain/lti/Lti13ClientRegistration.java index 16800e0021c7..816d18157234 100644 --- a/src/main/java/de/tum/in/www1/artemis/domain/lti/Lti13ClientRegistration.java +++ b/src/main/java/de/tum/in/www1/artemis/domain/lti/Lti13ClientRegistration.java @@ -37,6 +37,9 @@ public class Lti13ClientRegistration { @JsonProperty("jwks_uri") private String jwksUri; + @JsonProperty("logo_uri") + private String logoUri; + @JsonProperty("token_endpoint_auth_method") private String tokenEndpointAuthMethod; @@ -67,6 +70,7 @@ public Lti13ClientRegistration(String serverUrl, String clientRegistrationId) { this.setRedirectUris(List.of(serverUrl + "/" + CustomLti13Configurer.LTI13_LOGIN_REDIRECT_PROXY_PATH)); this.setInitiateLoginUri(serverUrl + "/" + CustomLti13Configurer.LTI13_LOGIN_INITIATION_PATH + "/" + clientRegistrationId); this.setJwksUri(serverUrl + "/.well-known/jwks.json"); + this.setLogoUri(serverUrl + "/public/images/logo.png"); Lti13ToolConfiguration toolConfiguration = getLti13ToolConfiguration(serverUrl); this.setLti13ToolConfiguration(toolConfiguration); @@ -83,6 +87,7 @@ private static Lti13ToolConfiguration getLti13ToolConfiguration(String serverUrl } toolConfiguration.setDomain(domain); toolConfiguration.setTargetLinkUri(serverUrl + "/courses"); + toolConfiguration.setDescription("Artemis: Interactive Learning with Individual Feedback"); toolConfiguration.setClaims(Arrays.asList("iss", "email", "sub", "name", "given_name", "family_name")); Message deepLinkingMessage = new Message(CustomLti13Configurer.LTI13_DEEPLINK_MESSAGE_REQUEST, serverUrl + "/" + CustomLti13Configurer.LTI13_DEEPLINK_REDIRECT_PATH); toolConfiguration.setMessages(List.of(deepLinkingMessage)); @@ -145,6 +150,14 @@ public void setJwksUri(String jwksUri) { this.jwksUri = jwksUri; } + public String getLogoUri() { + return logoUri; + } + + public void setLogoUri(String logoUri) { + this.logoUri = logoUri; + } + public String getTokenEndpointAuthMethod() { return tokenEndpointAuthMethod; } @@ -179,6 +192,8 @@ public static class Lti13ToolConfiguration { @JsonProperty("target_link_uri") private String targetLinkUri; + private String description; + private List messages; private List claims; @@ -199,6 +214,14 @@ public void setTargetLinkUri(String targetLinkUri) { this.targetLinkUri = targetLinkUri; } + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + public List getMessages() { return messages; } diff --git a/src/main/java/de/tum/in/www1/artemis/exception/GitLabCIException.java b/src/main/java/de/tum/in/www1/artemis/exception/GitLabCIException.java index 44f21ed0bf25..77279e2a6593 100644 --- a/src/main/java/de/tum/in/www1/artemis/exception/GitLabCIException.java +++ b/src/main/java/de/tum/in/www1/artemis/exception/GitLabCIException.java @@ -1,5 +1,8 @@ package de.tum.in.www1.artemis.exception; +// Gitlab support will be removed in 8.0.0. Please migrate to LocalVC using e.g. the PR https://github.com/ls1intum/Artemis/pull/8972 +@Deprecated(since = "7.5.0", forRemoval = true) + public class GitLabCIException extends ContinuousIntegrationException { public GitLabCIException(String message) { diff --git a/src/main/java/de/tum/in/www1/artemis/repository/CourseRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/CourseRepository.java index 470e789cdb27..1187d8c0e11d 100644 --- a/src/main/java/de/tum/in/www1/artemis/repository/CourseRepository.java +++ b/src/main/java/de/tum/in/www1/artemis/repository/CourseRepository.java @@ -222,8 +222,6 @@ SELECT COUNT(c) > 0 List findAllByShortName(String shortName); - Optional findById(long courseId); - /** * Returns the title of the course with the given id. * diff --git a/src/main/java/de/tum/in/www1/artemis/repository/ExamRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/ExamRepository.java index 127e51c9efab..afac757c9ccd 100644 --- a/src/main/java/de/tum/in/www1/artemis/repository/ExamRepository.java +++ b/src/main/java/de/tum/in/www1/artemis/repository/ExamRepository.java @@ -58,7 +58,7 @@ public interface ExamRepository extends ArtemisJpaRepository { /** * Find all exams for multiple courses that are already visible to the user (either registered, at least tutor or the exam is a test exam) * - * @param courseIds set of courseIds that the exams should be retreived + * @param courseIds set of courseIds that the exams should be retrieved * @param userId the id of the user requesting the exams * @param groupNames the groups of the user requesting the exams * @param now the current date, typically ZonedDateTime.now() diff --git a/src/main/java/de/tum/in/www1/artemis/repository/ExamSessionRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/ExamSessionRepository.java index b86b9ea3abc3..3924394b81ee 100644 --- a/src/main/java/de/tum/in/www1/artemis/repository/ExamSessionRepository.java +++ b/src/main/java/de/tum/in/www1/artemis/repository/ExamSessionRepository.java @@ -39,9 +39,9 @@ Set findAllExamSessionsWithTheSameIpAddressAndBrowserFingerprintByE @Param("studentExamId") Long studentExamId, @Param("ipAddress") String ipAddress, @Param("browserFingerprintHash") String browserFingerprintHash); @Query(""" - SELECT es - FROM ExamSession es - WHERE es.studentExam.exam.id = :examId + SELECT es + FROM ExamSession es + WHERE es.studentExam.exam.id = :examId """) Set findAllExamSessionsByExamId(@Param("examId") long examId); } diff --git a/src/main/java/de/tum/in/www1/artemis/repository/UserRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/UserRepository.java index e659f82db28d..04dd3b8732f2 100644 --- a/src/main/java/de/tum/in/www1/artemis/repository/UserRepository.java +++ b/src/main/java/de/tum/in/www1/artemis/repository/UserRepository.java @@ -765,6 +765,17 @@ default Page findAllWithGroupsByIsDeletedIsFalse(Pageable pageable) { """) void updateUserSshPublicKeyHash(@Param("userId") long userId, @Param("sshPublicKeyHash") String sshPublicKeyHash, @Param("sshPublicKey") String sshPublicKey); + @Modifying + @Transactional // ok because of modifying query + @Query(""" + UPDATE User user + SET user.vcsAccessToken = :vcsAccessToken, + user.vcsAccessTokenExpiryDate = :vcsAccessTokenExpiryDate + WHERE user.id = :userId + """) + void updateUserVcsAccessToken(@Param("userId") long userId, @Param("vcsAccessToken") String vcsAccessToken, + @Param("vcsAccessTokenExpiryDate") ZonedDateTime vcsAccessTokenExpiryDate); + @Modifying @Transactional // ok because of modifying query @Query(""" diff --git a/src/main/java/de/tum/in/www1/artemis/repository/iris/IrisCompetencyGenerationSessionRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/iris/IrisCompetencyGenerationSessionRepository.java deleted file mode 100644 index 978749785907..000000000000 --- a/src/main/java/de/tum/in/www1/artemis/repository/iris/IrisCompetencyGenerationSessionRepository.java +++ /dev/null @@ -1,20 +0,0 @@ -package de.tum.in.www1.artemis.repository.iris; - -import de.tum.in.www1.artemis.domain.iris.session.IrisCompetencyGenerationSession; -import de.tum.in.www1.artemis.repository.base.ArtemisJpaRepository; - -/** - * Repository interface for managing {@link IrisCompetencyGenerationSession} entities. - */ -public interface IrisCompetencyGenerationSessionRepository extends ArtemisJpaRepository { - - /** - * Finds the latest {@link IrisCompetencyGenerationSession} based on its course and user. - * - * @param courseId The ID of the course. - * @param userId The ID of the user. - * - * @return The latest competency generation session - */ - IrisCompetencyGenerationSession findFirstByCourseIdAndUserIdOrderByCreationDateDesc(long courseId, long userId); -} diff --git a/src/main/java/de/tum/in/www1/artemis/service/AuthorizationCheckService.java b/src/main/java/de/tum/in/www1/artemis/service/AuthorizationCheckService.java index 4143a0e00bbe..724a8f98e347 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/AuthorizationCheckService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/AuthorizationCheckService.java @@ -10,8 +10,6 @@ import jakarta.annotation.Nullable; import jakarta.validation.constraints.NotNull; -import javax.annotation.CheckReturnValue; - import org.hibernate.Hibernate; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; @@ -19,6 +17,8 @@ import org.springframework.stereotype.Service; import org.springframework.web.server.ResponseStatusException; +import com.google.errorprone.annotations.CheckReturnValue; + import de.tum.in.www1.artemis.domain.Authority; import de.tum.in.www1.artemis.domain.Course; import de.tum.in.www1.artemis.domain.Exercise; diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/ci/ContinuousIntegrationService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/ci/ContinuousIntegrationService.java index f22eebc954bf..2cb70653b3d4 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/ci/ContinuousIntegrationService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/ci/ContinuousIntegrationService.java @@ -219,8 +219,8 @@ enum RepositoryCheckoutPath implements CustomizableCheckoutPath { @Override public String forProgrammingLanguage(ProgrammingLanguage language) { return switch (language) { - case JAVA, PYTHON, C, HASKELL, KOTLIN, VHDL, ASSEMBLER, SWIFT, OCAML, EMPTY -> "assignment"; - case JAVASCRIPT, C_SHARP, C_PLUS_PLUS, SQL, R, TYPESCRIPT, RUST, GO, MATLAB, BASH, RUBY, POWERSHELL, ADA, DART, PHP -> + case JAVA, PYTHON, C, HASKELL, KOTLIN, VHDL, ASSEMBLER, SWIFT, OCAML, EMPTY, RUST -> "assignment"; + case JAVASCRIPT, C_SHARP, C_PLUS_PLUS, SQL, R, TYPESCRIPT, GO, MATLAB, BASH, RUBY, POWERSHELL, ADA, DART, PHP -> throw new UnsupportedOperationException("Unsupported programming language: " + language); }; } @@ -230,9 +230,9 @@ public String forProgrammingLanguage(ProgrammingLanguage language) { @Override public String forProgrammingLanguage(ProgrammingLanguage language) { return switch (language) { - case JAVA, PYTHON, HASKELL, KOTLIN, SWIFT, EMPTY -> ""; + case JAVA, PYTHON, HASKELL, KOTLIN, SWIFT, EMPTY, RUST -> ""; case C, VHDL, ASSEMBLER, OCAML -> "tests"; - case JAVASCRIPT, C_SHARP, C_PLUS_PLUS, SQL, R, TYPESCRIPT, RUST, GO, MATLAB, BASH, RUBY, POWERSHELL, ADA, DART, PHP -> + case JAVASCRIPT, C_SHARP, C_PLUS_PLUS, SQL, R, TYPESCRIPT, GO, MATLAB, BASH, RUBY, POWERSHELL, ADA, DART, PHP -> throw new UnsupportedOperationException("Unsupported programming language: " + language); }; } diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/AbstractGitLabAuthorizationInterceptor.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/AbstractGitLabAuthorizationInterceptor.java index 4bf736963136..1b27d35bd3dd 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/AbstractGitLabAuthorizationInterceptor.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/AbstractGitLabAuthorizationInterceptor.java @@ -10,6 +10,9 @@ import org.springframework.http.client.ClientHttpRequestInterceptor; import org.springframework.http.client.ClientHttpResponse; +// Gitlab support will be removed in 8.0.0. Please migrate to LocalVC using e.g. the PR https://github.com/ls1intum/Artemis/pull/8972 +@Deprecated(since = "7.5.0", forRemoval = true) + public abstract class AbstractGitLabAuthorizationInterceptor implements ClientHttpRequestInterceptor { private static final String GITLAB_AUTHORIZATION_HEADER_NAME = "Private-Token"; diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/GitLabAuthorizationInterceptor.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/GitLabAuthorizationInterceptor.java index 9a78a348d9d8..2e461a5e1106 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/GitLabAuthorizationInterceptor.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/GitLabAuthorizationInterceptor.java @@ -3,6 +3,9 @@ import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; +// Gitlab support will be removed in 8.0.0. Please migrate to LocalVC using e.g. the PR https://github.com/ls1intum/Artemis/pull/8972 +@Deprecated(since = "7.5.0", forRemoval = true) + @Profile("gitlab") @Component public class GitLabAuthorizationInterceptor extends AbstractGitLabAuthorizationInterceptor { diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/GitLabException.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/GitLabException.java index b34cd9c61dba..f217ef874422 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/GitLabException.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/GitLabException.java @@ -2,6 +2,9 @@ import de.tum.in.www1.artemis.exception.VersionControlException; +// Gitlab support will be removed in 8.0.0. Please migrate to LocalVC using e.g. the PR https://github.com/ls1intum/Artemis/pull/8972 +@Deprecated(since = "7.5.0", forRemoval = true) + public class GitLabException extends VersionControlException { public GitLabException() { diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/GitLabPersonalAccessTokenManagementService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/GitLabPersonalAccessTokenManagementService.java index e5d5fb7b3c4e..e903ffe004f5 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/GitLabPersonalAccessTokenManagementService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/GitLabPersonalAccessTokenManagementService.java @@ -31,6 +31,9 @@ /** * Provides VCS access token services for GitLab via means of personal access tokens. */ +// Gitlab support will be removed in 8.0.0. Please migrate to LocalVC using e.g. the PR https://github.com/ls1intum/Artemis/pull/8972 +@Deprecated(since = "7.5.0", forRemoval = true) + @Service @Profile("gitlab") public class GitLabPersonalAccessTokenManagementService extends VcsTokenManagementService { diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/GitLabService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/GitLabService.java index 103a58652025..4cdddb4cf7a1 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/GitLabService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/GitLabService.java @@ -66,6 +66,9 @@ import de.tum.in.www1.artemis.service.connectors.vcs.VersionControlRepositoryPermission; import de.tum.in.www1.artemis.service.util.UrlUtils; +// Gitlab support will be removed in 8.0.0. Please migrate to LocalVC using e.g. the PR https://github.com/ls1intum/Artemis/pull/8972 +@Deprecated(since = "7.5.0", forRemoval = true) + @Profile("gitlab") @Service public class GitLabService extends AbstractVersionControlService { diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/GitLabUserDoesNotExistException.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/GitLabUserDoesNotExistException.java index 298dee6f4e1b..36f65cc51902 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/GitLabUserDoesNotExistException.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/GitLabUserDoesNotExistException.java @@ -1,5 +1,8 @@ package de.tum.in.www1.artemis.service.connectors.gitlab; +// Gitlab support will be removed in 8.0.0. Please migrate to LocalVC using e.g. the PR https://github.com/ls1intum/Artemis/pull/8972 +@Deprecated(since = "7.5.0", forRemoval = true) + public class GitLabUserDoesNotExistException extends GitLabException { public GitLabUserDoesNotExistException(String login) { diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/GitLabUserManagementService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/GitLabUserManagementService.java index 9733c7a131c0..0f8f67198b55 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/GitLabUserManagementService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/GitLabUserManagementService.java @@ -28,6 +28,9 @@ import de.tum.in.www1.artemis.service.connectors.vcs.VcsTokenManagementService; import de.tum.in.www1.artemis.service.connectors.vcs.VcsUserManagementService; +// Gitlab support will be removed in 8.0.0. Please migrate to LocalVC using e.g. the PR https://github.com/ls1intum/Artemis/pull/8972 +@Deprecated(since = "7.5.0", forRemoval = true) + @Service @Profile("gitlab") public class GitLabUserManagementService implements VcsUserManagementService { diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/GitlabInfoContributor.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/GitlabInfoContributor.java index b24a6013dc93..951aafa36d8d 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/GitlabInfoContributor.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/GitlabInfoContributor.java @@ -11,6 +11,9 @@ import de.tum.in.www1.artemis.config.Constants; +// Gitlab support will be removed in 8.0.0. Please migrate to LocalVC using e.g. the PR https://github.com/ls1intum/Artemis/pull/8972 +@Deprecated(since = "7.5.0", forRemoval = true) + @Component @Profile("gitlab") public class GitlabInfoContributor implements InfoContributor { diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/dto/GitLabCommitDTO.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/dto/GitLabCommitDTO.java index 6942e2da6343..d937d570bb45 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/dto/GitLabCommitDTO.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/dto/GitLabCommitDTO.java @@ -7,6 +7,9 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +// Gitlab support will be removed in 8.0.0. Please migrate to LocalVC using e.g. the PR https://github.com/ls1intum/Artemis/pull/8972 +@Deprecated(since = "7.5.0", forRemoval = true) + @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_EMPTY) public record GitLabCommitDTO(@JsonProperty("id") String hash, String message, ZonedDateTime timestamp, @JsonProperty("url") String commitUrl, Author author, List added, diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/dto/GitLabPersonalAccessTokenListResponseDTO.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/dto/GitLabPersonalAccessTokenListResponseDTO.java index 2662aef4d7d7..0ceec1bc02dc 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/dto/GitLabPersonalAccessTokenListResponseDTO.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/dto/GitLabPersonalAccessTokenListResponseDTO.java @@ -9,6 +9,9 @@ * * @param id The id of the personal access token. */ +// Gitlab support will be removed in 8.0.0. Please migrate to LocalVC using e.g. the PR https://github.com/ls1intum/Artemis/pull/8972 +@Deprecated(since = "7.5.0", forRemoval = true) + @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_EMPTY) public record GitLabPersonalAccessTokenListResponseDTO(@JsonProperty Long id) { diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/dto/GitLabPersonalAccessTokenRequestDTO.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/dto/GitLabPersonalAccessTokenRequestDTO.java index 263b15057000..154830c818b3 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/dto/GitLabPersonalAccessTokenRequestDTO.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/dto/GitLabPersonalAccessTokenRequestDTO.java @@ -6,6 +6,9 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +// Gitlab support will be removed in 8.0.0. Please migrate to LocalVC using e.g. the PR https://github.com/ls1intum/Artemis/pull/8972 +@Deprecated(since = "7.5.0", forRemoval = true) + @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_EMPTY) public record GitLabPersonalAccessTokenRequestDTO(String name, @JsonProperty("user_id") Long userId, String[] scopes, @JsonProperty("expires_at") Date expiresAt) { diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/dto/GitLabPersonalAccessTokenResponseDTO.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/dto/GitLabPersonalAccessTokenResponseDTO.java index e8f4333975f7..6920d3db2bee 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/dto/GitLabPersonalAccessTokenResponseDTO.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/dto/GitLabPersonalAccessTokenResponseDTO.java @@ -6,6 +6,9 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +// Gitlab support will be removed in 8.0.0. Please migrate to LocalVC using e.g. the PR https://github.com/ls1intum/Artemis/pull/8972 +@Deprecated(since = "7.5.0", forRemoval = true) + @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_EMPTY) public record GitLabPersonalAccessTokenResponseDTO(String name, @JsonProperty("user_id") Long userId, String[] scopes, @JsonProperty("expires_at") Date expiresAt, String token) { diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/dto/GitLabProjectDTO.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/dto/GitLabProjectDTO.java index 605679d176bf..71353195ac21 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/dto/GitLabProjectDTO.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/dto/GitLabProjectDTO.java @@ -6,6 +6,9 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +// Gitlab support will be removed in 8.0.0. Please migrate to LocalVC using e.g. the PR https://github.com/ls1intum/Artemis/pull/8972 +@Deprecated(since = "7.5.0", forRemoval = true) + @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_EMPTY) public record GitLabProjectDTO(int id, String name, String description, @JsonProperty("web_url") URL webUrl, @JsonProperty("git_ssh_url") String sshUrl, diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/dto/GitLabPushNotificationDTO.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/dto/GitLabPushNotificationDTO.java index 23858285deda..917628db09e8 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/dto/GitLabPushNotificationDTO.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/dto/GitLabPushNotificationDTO.java @@ -8,6 +8,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +// Gitlab support will be removed in 8.0.0. Please migrate to LocalVC using e.g. the PR https://github.com/ls1intum/Artemis/pull/8972 +@Deprecated(since = "7.5.0", forRemoval = true) + @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_EMPTY) public record GitLabPushNotificationDTO(@JsonProperty("object_kind") String triggerType, @JsonProperty("event_name") String eventName, @JsonProperty("before") String previousHash, diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/dto/GitLabRepositoryDTO.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/dto/GitLabRepositoryDTO.java index f4e84034af8c..245eefed4498 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/dto/GitLabRepositoryDTO.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlab/dto/GitLabRepositoryDTO.java @@ -5,6 +5,9 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; +// Gitlab support will be removed in 8.0.0. Please migrate to LocalVC using e.g. the PR https://github.com/ls1intum/Artemis/pull/8972 +@Deprecated(since = "7.5.0", forRemoval = true) + @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_EMPTY) public record GitLabRepositoryDTO(String name, String url, String description, URL homepage) { diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlabci/GitLabCIAuthorizationInterceptor.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlabci/GitLabCIAuthorizationInterceptor.java index 79de2d27aa42..258aa2df4269 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlabci/GitLabCIAuthorizationInterceptor.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlabci/GitLabCIAuthorizationInterceptor.java @@ -5,6 +5,9 @@ import de.tum.in.www1.artemis.service.connectors.gitlab.AbstractGitLabAuthorizationInterceptor; +// Gitlab support will be removed in 8.0.0. Please migrate to LocalVC using e.g. the PR https://github.com/ls1intum/Artemis/pull/8972 +@Deprecated(since = "7.5.0", forRemoval = true) + @Profile("gitlabci") @Component public class GitLabCIAuthorizationInterceptor extends AbstractGitLabAuthorizationInterceptor { diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlabci/GitLabCIBuildPlanService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlabci/GitLabCIBuildPlanService.java index 80e9dab30be0..23500df008f1 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlabci/GitLabCIBuildPlanService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlabci/GitLabCIBuildPlanService.java @@ -19,6 +19,9 @@ import de.tum.in.www1.artemis.service.ResourceLoaderService; import de.tum.in.www1.artemis.service.connectors.ci.AbstractBuildPlanCreator; +// Gitlab support will be removed in 8.0.0. Please migrate to LocalVC using e.g. the PR https://github.com/ls1intum/Artemis/pull/8972 +@Deprecated(since = "7.5.0", forRemoval = true) + @Service @Profile("gitlabci") public class GitLabCIBuildPlanService extends AbstractBuildPlanCreator { diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlabci/GitLabCIInfoContributor.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlabci/GitLabCIInfoContributor.java index 832d100a7585..ee088f9d901a 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlabci/GitLabCIInfoContributor.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlabci/GitLabCIInfoContributor.java @@ -10,6 +10,9 @@ import de.tum.in.www1.artemis.config.Constants; +// Gitlab support will be removed in 8.0.0. Please migrate to LocalVC using e.g. the PR https://github.com/ls1intum/Artemis/pull/8972 +@Deprecated(since = "7.5.0", forRemoval = true) + @Component @Profile("gitlabci") public class GitLabCIInfoContributor implements InfoContributor { diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlabci/GitLabCIProgrammingLanguageFeatureService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlabci/GitLabCIProgrammingLanguageFeatureService.java index 55ecbb0e447c..661824ea06d8 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlabci/GitLabCIProgrammingLanguageFeatureService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlabci/GitLabCIProgrammingLanguageFeatureService.java @@ -2,6 +2,7 @@ import static de.tum.in.www1.artemis.domain.enumeration.ProgrammingLanguage.EMPTY; import static de.tum.in.www1.artemis.domain.enumeration.ProgrammingLanguage.JAVA; +import static de.tum.in.www1.artemis.domain.enumeration.ProgrammingLanguage.RUST; import static de.tum.in.www1.artemis.domain.enumeration.ProjectType.MAVEN_MAVEN; import static de.tum.in.www1.artemis.domain.enumeration.ProjectType.PLAIN_MAVEN; @@ -13,6 +14,9 @@ import de.tum.in.www1.artemis.service.programming.ProgrammingLanguageFeature; import de.tum.in.www1.artemis.service.programming.ProgrammingLanguageFeatureService; +// Gitlab support will be removed in 8.0.0. Please migrate to LocalVC using e.g. the PR https://github.com/ls1intum/Artemis/pull/8972 +@Deprecated(since = "7.5.0", forRemoval = true) + @Service @Profile("gitlabci") public class GitLabCIProgrammingLanguageFeatureService extends ProgrammingLanguageFeatureService { @@ -20,5 +24,6 @@ public class GitLabCIProgrammingLanguageFeatureService extends ProgrammingLangua public GitLabCIProgrammingLanguageFeatureService() { programmingLanguageFeatures.put(EMPTY, new ProgrammingLanguageFeature(EMPTY, false, false, false, false, false, List.of(), false, false)); programmingLanguageFeatures.put(JAVA, new ProgrammingLanguageFeature(JAVA, false, false, false, true, false, List.of(PLAIN_MAVEN, MAVEN_MAVEN), false, false)); + programmingLanguageFeatures.put(RUST, new ProgrammingLanguageFeature(RUST, false, false, false, false, false, List.of(), false, false)); } } diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlabci/GitLabCIResultService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlabci/GitLabCIResultService.java index 620e22e09a55..f71e0fe8253b 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlabci/GitLabCIResultService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlabci/GitLabCIResultService.java @@ -25,6 +25,9 @@ import de.tum.in.www1.artemis.service.hestia.TestwiseCoverageService; import de.tum.in.www1.artemis.service.programming.ProgrammingExerciseFeedbackCreationService; +// Gitlab support will be removed in 8.0.0. Please migrate to LocalVC using e.g. the PR https://github.com/ls1intum/Artemis/pull/8972 +@Deprecated(since = "7.5.0", forRemoval = true) + @Profile("gitlabci") @Service public class GitLabCIResultService extends AbstractContinuousIntegrationResultService { diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlabci/GitLabCIService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlabci/GitLabCIService.java index 2c78eb608c4e..45d66cf19272 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlabci/GitLabCIService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlabci/GitLabCIService.java @@ -42,6 +42,9 @@ import de.tum.in.www1.artemis.service.connectors.ci.notification.dto.TestResultsDTO; import de.tum.in.www1.artemis.web.rest.dto.CheckoutDirectoriesDTO; +// Gitlab support will be removed in 8.0.0. Please migrate to LocalVC using e.g. the PR https://github.com/ls1intum/Artemis/pull/8972 +@Deprecated(since = "7.5.0", forRemoval = true) + @Profile("gitlabci") @Service public class GitLabCIService extends AbstractContinuousIntegrationService { diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlabci/GitLabCITriggerService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlabci/GitLabCITriggerService.java index 72c4fcb6150b..d2fe1c62525f 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlabci/GitLabCITriggerService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlabci/GitLabCITriggerService.java @@ -15,6 +15,9 @@ import de.tum.in.www1.artemis.service.UriService; import de.tum.in.www1.artemis.service.connectors.ci.ContinuousIntegrationTriggerService; +// Gitlab support will be removed in 8.0.0. Please migrate to LocalVC using e.g. the PR https://github.com/ls1intum/Artemis/pull/8972 +@Deprecated(since = "7.5.0", forRemoval = true) + @Profile("gitlabci") @Service public class GitLabCITriggerService implements ContinuousIntegrationTriggerService { diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlabci/GitLabCIUserManagementService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlabci/GitLabCIUserManagementService.java index f70dd7a82ffa..e15e0dd7038f 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlabci/GitLabCIUserManagementService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/gitlabci/GitLabCIUserManagementService.java @@ -12,6 +12,9 @@ import de.tum.in.www1.artemis.exception.ContinuousIntegrationException; import de.tum.in.www1.artemis.service.connectors.ci.CIUserManagementService; +// Gitlab support will be removed in 8.0.0. Please migrate to LocalVC using e.g. the PR https://github.com/ls1intum/Artemis/pull/8972 +@Deprecated(since = "7.5.0", forRemoval = true) + @Service @Profile("gitlabci") public class GitLabCIUserManagementService implements CIUserManagementService { diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/jenkins/JenkinsProgrammingLanguageFeatureService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/jenkins/JenkinsProgrammingLanguageFeatureService.java index 533e4c709005..d8a4abd0fea3 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/jenkins/JenkinsProgrammingLanguageFeatureService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/jenkins/JenkinsProgrammingLanguageFeatureService.java @@ -6,6 +6,7 @@ import static de.tum.in.www1.artemis.domain.enumeration.ProgrammingLanguage.JAVA; import static de.tum.in.www1.artemis.domain.enumeration.ProgrammingLanguage.KOTLIN; import static de.tum.in.www1.artemis.domain.enumeration.ProgrammingLanguage.PYTHON; +import static de.tum.in.www1.artemis.domain.enumeration.ProgrammingLanguage.RUST; import static de.tum.in.www1.artemis.domain.enumeration.ProgrammingLanguage.SWIFT; import static de.tum.in.www1.artemis.domain.enumeration.ProjectType.FACT; import static de.tum.in.www1.artemis.domain.enumeration.ProjectType.GCC; @@ -39,5 +40,6 @@ public JenkinsProgrammingLanguageFeatureService() { programmingLanguageFeatures.put(SWIFT, new ProgrammingLanguageFeature(SWIFT, false, true, true, true, false, List.of(PLAIN), false, false)); programmingLanguageFeatures.put(C, new ProgrammingLanguageFeature(C, false, false, true, false, false, List.of(FACT, GCC), false, false)); programmingLanguageFeatures.put(HASKELL, new ProgrammingLanguageFeature(HASKELL, false, false, false, false, true, List.of(), false, false)); + programmingLanguageFeatures.put(RUST, new ProgrammingLanguageFeature(RUST, false, false, false, false, false, List.of(), false, false)); } } diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/jenkins/build_plan/JenkinsBuildPlanService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/jenkins/build_plan/JenkinsBuildPlanService.java index a97f293e1b53..929a9e1e5750 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/jenkins/build_plan/JenkinsBuildPlanService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/jenkins/build_plan/JenkinsBuildPlanService.java @@ -184,8 +184,8 @@ private JenkinsXmlConfigBuilder builderFor(ProgrammingLanguage programmingLangua throw new UnsupportedOperationException("Xcode templates are not available for Jenkins."); } return switch (programmingLanguage) { - case JAVA, KOTLIN, PYTHON, C, HASKELL, SWIFT, EMPTY -> jenkinsBuildPlanCreator; - case VHDL, ASSEMBLER, OCAML, JAVASCRIPT, C_SHARP, C_PLUS_PLUS, SQL, R, TYPESCRIPT, RUST, GO, MATLAB, BASH, RUBY, POWERSHELL, ADA, DART, PHP -> + case JAVA, KOTLIN, PYTHON, C, HASKELL, SWIFT, EMPTY, RUST -> jenkinsBuildPlanCreator; + case VHDL, ASSEMBLER, OCAML, JAVASCRIPT, C_SHARP, C_PLUS_PLUS, SQL, R, TYPESCRIPT, GO, MATLAB, BASH, RUBY, POWERSHELL, ADA, DART, PHP -> throw new UnsupportedOperationException(programmingLanguage + " templates are not available for Jenkins."); }; } diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/LocalCIProgrammingLanguageFeatureService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/LocalCIProgrammingLanguageFeatureService.java index 276ab23671fa..101e0540521f 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/LocalCIProgrammingLanguageFeatureService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/LocalCIProgrammingLanguageFeatureService.java @@ -8,6 +8,7 @@ import static de.tum.in.www1.artemis.domain.enumeration.ProgrammingLanguage.KOTLIN; import static de.tum.in.www1.artemis.domain.enumeration.ProgrammingLanguage.OCAML; import static de.tum.in.www1.artemis.domain.enumeration.ProgrammingLanguage.PYTHON; +import static de.tum.in.www1.artemis.domain.enumeration.ProgrammingLanguage.RUST; import static de.tum.in.www1.artemis.domain.enumeration.ProgrammingLanguage.SWIFT; import static de.tum.in.www1.artemis.domain.enumeration.ProgrammingLanguage.VHDL; import static de.tum.in.www1.artemis.domain.enumeration.ProjectType.FACT; @@ -46,5 +47,6 @@ public LocalCIProgrammingLanguageFeatureService() { programmingLanguageFeatures.put(HASKELL, new ProgrammingLanguageFeature(HASKELL, true, false, false, false, true, List.of(), false, true)); programmingLanguageFeatures.put(OCAML, new ProgrammingLanguageFeature(OCAML, false, false, false, false, true, List.of(), false, true)); programmingLanguageFeatures.put(SWIFT, new ProgrammingLanguageFeature(SWIFT, false, false, true, true, false, List.of(PLAIN), false, true)); + programmingLanguageFeatures.put(RUST, new ProgrammingLanguageFeature(RUST, false, false, false, false, false, List.of(), false, true)); } } diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/LocalCIResultProcessingService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/LocalCIResultProcessingService.java index fd7e0cc0dc89..dfc7dcf776c2 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/LocalCIResultProcessingService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/LocalCIResultProcessingService.java @@ -20,7 +20,6 @@ import com.hazelcast.collection.ItemEvent; import com.hazelcast.collection.ItemListener; import com.hazelcast.core.HazelcastInstance; -import com.hazelcast.cp.lock.FencedLock; import com.hazelcast.map.IMap; import de.tum.in.www1.artemis.domain.BuildJob; @@ -73,8 +72,6 @@ public class LocalCIResultProcessingService { private IMap buildAgentInformation; - private FencedLock resultQueueLock; - private UUID listenerId; public LocalCIResultProcessingService(@Qualifier("hazelcastInstance") HazelcastInstance hazelcastInstance, ProgrammingExerciseGradingService programmingExerciseGradingService, @@ -97,7 +94,6 @@ public LocalCIResultProcessingService(@Qualifier("hazelcastInstance") HazelcastI public void init() { this.resultQueue = this.hazelcastInstance.getQueue("buildResultQueue"); this.buildAgentInformation = this.hazelcastInstance.getMap("buildAgentInformation"); - this.resultQueueLock = this.hazelcastInstance.getCPSubsystem().getLock("resultQueueLock"); this.listenerId = resultQueue.addItemListener(new ResultQueueListener(), true); } @@ -112,9 +108,7 @@ public void removeListener() { public void processResult() { // set lock to prevent multiple nodes from processing the same build job - resultQueueLock.lock(); ResultQueueItem resultQueueItem = resultQueue.poll(); - resultQueueLock.unlock(); if (resultQueueItem == null) { return; diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/SharedQueueManagementService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/SharedQueueManagementService.java index bdaea1f46fd5..939fa41cee19 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/SharedQueueManagementService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/SharedQueueManagementService.java @@ -27,7 +27,6 @@ import com.hazelcast.collection.IQueue; import com.hazelcast.core.HazelcastInstance; -import com.hazelcast.cp.lock.FencedLock; import com.hazelcast.map.IMap; import com.hazelcast.topic.ITopic; @@ -66,11 +65,6 @@ public class SharedQueueManagementService { private IMap dockerImageCleanupInfo; - /** - * Lock to prevent multiple nodes from processing the same build job. - */ - private FencedLock sharedLock; - private ITopic canceledBuildJobsTopic; public SharedQueueManagementService(BuildJobRepository buildJobRepository, @Qualifier("hazelcastInstance") HazelcastInstance hazelcastInstance, ProfileService profileService) { @@ -86,7 +80,6 @@ public SharedQueueManagementService(BuildJobRepository buildJobRepository, @Qual public void init() { this.buildAgentInformation = this.hazelcastInstance.getMap("buildAgentInformation"); this.processingJobs = this.hazelcastInstance.getMap("processingJobs"); - this.sharedLock = this.hazelcastInstance.getCPSubsystem().getLock("buildJobQueueLock"); this.queue = this.hazelcastInstance.getQueue("buildJobQueue"); this.canceledBuildJobsTopic = hazelcastInstance.getTopic("canceledBuildJobsTopic"); this.dockerImageCleanupInfo = this.hazelcastInstance.getMap("dockerImageCleanupInfo"); @@ -148,28 +141,22 @@ public List getBuildAgentInformationWithoutRecentBuildJob * @param buildJobId id of the build job to cancel */ public void cancelBuildJob(String buildJobId) { - sharedLock.lock(); - try { - // Remove build job if it is queued - if (queue.stream().anyMatch(job -> Objects.equals(job.id(), buildJobId))) { - List toRemove = new ArrayList<>(); - for (BuildJobQueueItem job : queue) { - if (Objects.equals(job.id(), buildJobId)) { - toRemove.add(job); - } - } - queue.removeAll(toRemove); - } - else { - // Cancel build job if it is currently being processed - BuildJobQueueItem buildJob = processingJobs.remove(buildJobId); - if (buildJob != null) { - triggerBuildJobCancellation(buildJobId); + // Remove build job if it is queued + if (queue.stream().anyMatch(job -> Objects.equals(job.id(), buildJobId))) { + List toRemove = new ArrayList<>(); + for (BuildJobQueueItem job : queue) { + if (Objects.equals(job.id(), buildJobId)) { + toRemove.add(job); } } + queue.removeAll(toRemove); } - finally { - sharedLock.unlock(); + else { + // Cancel build job if it is currently being processed + BuildJobQueueItem buildJob = processingJobs.remove(buildJobId); + if (buildJob != null) { + triggerBuildJobCancellation(buildJobId); + } } } @@ -188,30 +175,17 @@ private void triggerBuildJobCancellation(String buildJobId) { * Cancel all queued build jobs. */ public void cancelAllQueuedBuildJobs() { - sharedLock.lock(); - try { - log.debug("Cancelling all queued build jobs"); - queue.clear(); - } - finally { - sharedLock.unlock(); - } + log.debug("Cancelling all queued build jobs"); + queue.clear(); } /** * Cancel all running build jobs. */ public void cancelAllRunningBuildJobs() { - sharedLock.lock(); - try { - for (BuildJobQueueItem buildJob : processingJobs.values()) { - cancelBuildJob(buildJob.id()); - } - } - finally { - sharedLock.unlock(); + for (BuildJobQueueItem buildJob : processingJobs.values()) { + cancelBuildJob(buildJob.id()); } - } /** @@ -220,13 +194,7 @@ public void cancelAllRunningBuildJobs() { * @param agentName name of the agent */ public void cancelAllRunningBuildJobsForAgent(String agentName) { - sharedLock.lock(); - try { - processingJobs.values().stream().filter(job -> Objects.equals(job.buildAgentAddress(), agentName)).forEach(job -> cancelBuildJob(job.id())); - } - finally { - sharedLock.unlock(); - } + processingJobs.values().stream().filter(job -> Objects.equals(job.buildAgentAddress(), agentName)).forEach(job -> cancelBuildJob(job.id())); } /** @@ -235,19 +203,13 @@ public void cancelAllRunningBuildJobsForAgent(String agentName) { * @param courseId id of the course */ public void cancelAllQueuedBuildJobsForCourse(long courseId) { - sharedLock.lock(); - try { - List toRemove = new ArrayList<>(); - for (BuildJobQueueItem job : queue) { - if (job.courseId() == courseId) { - toRemove.add(job); - } + List toRemove = new ArrayList<>(); + for (BuildJobQueueItem job : queue) { + if (job.courseId() == courseId) { + toRemove.add(job); } - queue.removeAll(toRemove); - } - finally { - sharedLock.unlock(); } + queue.removeAll(toRemove); } /** @@ -269,25 +231,19 @@ public void cancelAllRunningBuildJobsForCourse(long courseId) { * @param participationId id of the participation */ public void cancelAllJobsForParticipation(long participationId) { - sharedLock.lock(); - try { - List toRemove = new ArrayList<>(); - for (BuildJobQueueItem queuedJob : queue) { - if (queuedJob.participationId() == participationId) { - toRemove.add(queuedJob); - } + List toRemove = new ArrayList<>(); + for (BuildJobQueueItem queuedJob : queue) { + if (queuedJob.participationId() == participationId) { + toRemove.add(queuedJob); } - queue.removeAll(toRemove); + } + queue.removeAll(toRemove); - for (BuildJobQueueItem runningJob : processingJobs.values()) { - if (runningJob.participationId() == participationId) { - cancelBuildJob(runningJob.id()); - } + for (BuildJobQueueItem runningJob : processingJobs.values()) { + if (runningJob.participationId() == participationId) { + cancelBuildJob(runningJob.id()); } } - finally { - sharedLock.unlock(); - } } /** diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/buildagent/SharedQueueProcessingService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/buildagent/SharedQueueProcessingService.java index 853b89493827..d19d0fae96ba 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/buildagent/SharedQueueProcessingService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/buildagent/SharedQueueProcessingService.java @@ -32,7 +32,6 @@ import com.hazelcast.collection.ItemEvent; import com.hazelcast.collection.ItemListener; import com.hazelcast.core.HazelcastInstance; -import com.hazelcast.cp.lock.FencedLock; import com.hazelcast.map.IMap; import de.tum.in.www1.artemis.domain.BuildLogEntry; @@ -65,11 +64,6 @@ public class SharedQueueProcessingService { private final BuildAgentSshKeyService buildAgentSSHKeyService; - /** - * Lock to prevent multiple nodes from processing the same build job. - */ - private FencedLock sharedLock; - private IQueue queue; private IQueue resultQueue; @@ -104,7 +98,6 @@ public SharedQueueProcessingService(@Qualifier("hazelcastInstance") HazelcastIns public void init() { this.buildAgentInformation = this.hazelcastInstance.getMap("buildAgentInformation"); this.processingJobs = this.hazelcastInstance.getMap("processingJobs"); - this.sharedLock = this.hazelcastInstance.getCPSubsystem().getLock("buildJobQueueLock"); this.queue = this.hazelcastInstance.getQueue("buildJobQueue"); this.resultQueue = this.hazelcastInstance.getQueue("buildResultQueue"); this.listenerId = this.queue.addItemListener(new QueuedBuildJobItemListener(), true); @@ -176,14 +169,8 @@ private void checkAvailabilityAndProcessNextBuild() { return; } - // Lock the queue to prevent multiple nodes from processing the same build job - sharedLock.lock(); - try { - buildJob = addToProcessingJobs(); - } - finally { - sharedLock.unlock(); - } + buildJob = addToProcessingJobs(); + processBuild(buildJob); } catch (RejectedExecutionException e) { diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/PyrisJobService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/PyrisJobService.java index 1c9320aed1d0..24dc20bffa65 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/PyrisJobService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/PyrisJobService.java @@ -2,6 +2,7 @@ import java.security.SecureRandom; import java.util.concurrent.TimeUnit; +import java.util.function.Function; import jakarta.annotation.PostConstruct; import jakarta.servlet.http.HttpServletRequest; @@ -19,6 +20,7 @@ import de.tum.in.www1.artemis.service.connectors.pyris.job.IngestionWebhookJob; import de.tum.in.www1.artemis.service.connectors.pyris.job.PyrisJob; import de.tum.in.www1.artemis.web.rest.errors.AccessForbiddenException; +import de.tum.in.www1.artemis.web.rest.errors.ConflictException; /** * The PyrisJobService class is responsible for managing Pyris jobs in the Artemis system. @@ -58,6 +60,20 @@ public void init() { jobMap = hazelcastInstance.getMap("pyris-job-map"); } + /** + * Creates a token for an arbitrary job, runs the provided function with the token as an argument, + * and stores the job in the job map. + * + * @param tokenToJobFunction the function to run with the token + * @return the generated token + */ + public String createTokenForJob(Function tokenToJobFunction) { + var token = generateJobIdToken(); + var job = tokenToJobFunction.apply(token); + jobMap.put(token, job); + return token; + } + public String addExerciseChatJob(Long courseId, Long exerciseId, Long sessionId) { var token = generateJobIdToken(); var job = new ExerciseChatJob(token, courseId, exerciseId, sessionId); @@ -111,13 +127,15 @@ public PyrisJob getJob(String token) { * 2. Retrieves the PyrisJob object associated with the provided token. * 3. Throws an AccessForbiddenException if the token is invalid or not provided. *

- * The token was previously generated via {@link #addJob(Long, Long, Long)} + * The token was previously generated via {@link #createTokenForJob(Function)} * - * @param request the HttpServletRequest object representing the incoming request + * @param request the HttpServletRequest object representing the incoming request + * @param jobClass the class of the PyrisJob object to cast the retrieved job to + * @param the type of the PyrisJob object * @return the PyrisJob object associated with the token * @throws AccessForbiddenException if the token is invalid or not provided */ - public PyrisJob getAndAuthenticateJobFromHeaderElseThrow(HttpServletRequest request) { + public Job getAndAuthenticateJobFromHeaderElseThrow(HttpServletRequest request, Class jobClass) { var authHeader = request.getHeader("Authorization"); if (!authHeader.startsWith("Bearer ")) { throw new AccessForbiddenException("No valid token provided"); @@ -127,7 +145,10 @@ public PyrisJob getAndAuthenticateJobFromHeaderElseThrow(HttpServletRequest requ if (job == null) { throw new AccessForbiddenException("No valid token provided"); } - return job; + if (!jobClass.isInstance(job)) { + throw new ConflictException("Run ID is not a " + jobClass.getSimpleName(), "Job", "invalidRunId"); + } + return jobClass.cast(job); } /** diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/PyrisPipelineService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/PyrisPipelineService.java index 54c8d2daf5f3..a2fc3f1c3ce4 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/PyrisPipelineService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/PyrisPipelineService.java @@ -6,8 +6,8 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.function.Consumer; import java.util.function.Function; -import java.util.function.Supplier; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -19,21 +19,19 @@ import de.tum.in.www1.artemis.domain.ProgrammingExercise; import de.tum.in.www1.artemis.domain.ProgrammingSubmission; import de.tum.in.www1.artemis.domain.competency.CompetencyJol; -import de.tum.in.www1.artemis.domain.iris.session.IrisChatSession; import de.tum.in.www1.artemis.domain.iris.session.IrisCourseChatSession; import de.tum.in.www1.artemis.domain.iris.session.IrisExerciseChatSession; import de.tum.in.www1.artemis.domain.participation.StudentParticipation; import de.tum.in.www1.artemis.repository.CourseRepository; import de.tum.in.www1.artemis.repository.StudentParticipationRepository; +import de.tum.in.www1.artemis.service.connectors.pyris.dto.PyrisPipelineExecutionDTO; import de.tum.in.www1.artemis.service.connectors.pyris.dto.PyrisPipelineExecutionSettingsDTO; -import de.tum.in.www1.artemis.service.connectors.pyris.dto.chat.PyrisChatPipelineExecutionBaseDataDTO; import de.tum.in.www1.artemis.service.connectors.pyris.dto.chat.course.PyrisCourseChatPipelineExecutionDTO; import de.tum.in.www1.artemis.service.connectors.pyris.dto.chat.exercise.PyrisExerciseChatPipelineExecutionDTO; import de.tum.in.www1.artemis.service.connectors.pyris.dto.data.PyrisCourseDTO; import de.tum.in.www1.artemis.service.connectors.pyris.dto.data.PyrisExtendedCourseDTO; import de.tum.in.www1.artemis.service.connectors.pyris.dto.data.PyrisUserDTO; import de.tum.in.www1.artemis.service.connectors.pyris.dto.status.PyrisStageDTO; -import de.tum.in.www1.artemis.service.connectors.pyris.dto.status.PyrisStageState; import de.tum.in.www1.artemis.service.iris.exception.IrisException; import de.tum.in.www1.artemis.service.iris.websocket.IrisChatWebsocketService; import de.tum.in.www1.artemis.service.metrics.LearningMetricsService; @@ -79,69 +77,48 @@ public PyrisPipelineService(PyrisConnectorService pyrisConnectorService, PyrisJo } /** - * Executes a chat pipeline for a given chat session subtype. - * This method prepares the execution data, executes the specified pipeline, and handles the state updates. + * Executes a pipeline on Pyris, identified by the given name and variant. + * The pipeline execution is tracked by a unique job token, which must be provided by the caller. + * The caller must additionally provide a mapper function to create the concrete DTO type for this pipeline from the base DTO. + * The status of the pipeline execution is updated via a consumer that accepts a list of stages. This method will + * call the consumer with the initial stages of the pipeline execution. Later stages will be sent back from Pyris, + * and need to be handled in the endpoint that receives the status updates. *

- * The general idea of this being generic is that the pipeline execution is the same for all chat sessions, - * but the specific data required for the pipeline execution is different for each session / pipeline type. - * Therefore, the specific data is provided by a function that accepts the basic chat data and returns the more specific data. * - * @param variant the variant of the pipeline - * @param session the active chat session, must inherit from {@link IrisChatSession} - * @param pipelineName the name of the pipeline to be executed - * @param executionDtoSupplier a function that accepts basic chat data and returns an execution DTO specific to the pipeline being executed - * @param jobTokenSupplier a supplier that provides a unique job token for tracking the pipeline execution - * @param the type of the chat session - * @param the type of the execution DTO + * @param name the name of the pipeline to be executed + * @param variant the variant of the pipeline + * @param jobToken a unique job token for tracking the pipeline execution + * @param dtoMapper a function to create the concrete DTO type for this pipeline from the base DTO + * @param statusUpdater a consumer to update the status of the pipeline execution */ - private void executeChatPipeline(String variant, T session, String pipelineName, - Function executionDtoSupplier, Supplier jobTokenSupplier) { - - // Retrieve the unique job token for this pipeline execution - var jobToken = jobTokenSupplier.get(); - - // Set up initial pipeline execution settings with the server base URL - var settingsDTO = new PyrisPipelineExecutionSettingsDTO(jobToken, List.of(), artemisBaseUrl); - + public void executePipeline(String name, String variant, String jobToken, Function dtoMapper, Consumer> statusUpdater) { // Define the preparation stages of pipeline execution with their initial states // There will be more stages added in Pyris later - var preparingRequestStageInProgress = new PyrisStageDTO("Preparing", 10, PyrisStageState.IN_PROGRESS, null); - var preparingRequestStageDone = new PyrisStageDTO("Preparing", 10, PyrisStageState.DONE, null); - var executingPipelineStageNotStarted = new PyrisStageDTO("Executing pipeline", 30, PyrisStageState.NOT_STARTED, null); + var preparing = new PyrisStageDTO("Preparing", 10, null, null); + var executing = new PyrisStageDTO("Executing pipeline", 30, null, null); // Send initial status update indicating that the preparation stage is in progress - irisChatWebsocketService.sendStatusUpdate(session, List.of(preparingRequestStageInProgress, executingPipelineStageNotStarted)); + statusUpdater.accept(List.of(preparing.inProgress(), executing.notStarted())); - try { - // Prepare the base execution data for the pipeline. - // It is shared among chat pipelines and included as field "base" in the specific execution DTOs. - var base = new PyrisChatPipelineExecutionBaseDataDTO(pyrisDTOService.toPyrisMessageDTOList(session.getMessages()), new PyrisUserDTO(session.getUser()), settingsDTO, - List.of(preparingRequestStageDone) // The initial stage is done when the request arrives at Pyris - ); - - // Prepare the specific execution data for the pipeline - // This is implementation-specific and includes additional data required for the pipeline - // Implementations must deliver the base data, too - U executionDTO = executionDtoSupplier.apply(base); + var baseDto = new PyrisPipelineExecutionDTO(new PyrisPipelineExecutionSettingsDTO(jobToken, List.of(), artemisBaseUrl), List.of(preparing.done())); + var pipelineDto = dtoMapper.apply(baseDto); + try { // Send a status update that preparation is done and pipeline execution is starting - var executingPipelineStageInProgress = new PyrisStageDTO("Executing pipeline", 30, PyrisStageState.IN_PROGRESS, null); - irisChatWebsocketService.sendStatusUpdate(session, List.of(preparingRequestStageDone, executingPipelineStageInProgress)); + statusUpdater.accept(List.of(preparing.done(), executing.inProgress())); try { // Execute the pipeline using the connector service - pyrisConnectorService.executePipeline(pipelineName, variant, executionDTO); + pyrisConnectorService.executePipeline(name, variant, pipelineDto); } catch (PyrisConnectorException | IrisException e) { - log.error("Failed to execute " + pipelineName + " pipeline", e); - var executingPipelineStageFailed = new PyrisStageDTO("Executing pipeline", 30, PyrisStageState.ERROR, "An internal error occurred"); - irisChatWebsocketService.sendStatusUpdate(session, List.of(preparingRequestStageDone, executingPipelineStageFailed)); + log.error("Failed to execute {} pipeline", name, e); + statusUpdater.accept(List.of(preparing.done(), executing.error("An internal error occurred"))); } } catch (Exception e) { - log.error("Failed to prepare " + pipelineName + " pipeline execution", e); - var preparingRequestStageFailed = new PyrisStageDTO("Preparing request", 10, PyrisStageState.ERROR, "An internal error occurred"); - irisChatWebsocketService.sendStatusUpdate(session, List.of(preparingRequestStageFailed, executingPipelineStageNotStarted)); + log.error("Failed to prepare {} pipeline execution", name, e); + statusUpdater.accept(List.of(preparing.error("An internal error occurred"), executing.notStarted())); } } @@ -157,14 +134,29 @@ private void executeChatPipeline(String variant, * @param latestSubmission the latest submission of the student * @param exercise the programming exercise * @param session the chat session - * @see PyrisPipelineService#executeChatPipeline for more details on the pipeline execution process. + * @see PyrisPipelineService#executePipeline for more details on the pipeline execution process. */ public void executeExerciseChatPipeline(String variant, Optional latestSubmission, ProgrammingExercise exercise, IrisExerciseChatSession session) { - executeChatPipeline(variant, session, "tutor-chat", // TODO: Rename this to 'exercise-chat' with next breaking Pyris version - base -> new PyrisExerciseChatPipelineExecutionDTO(latestSubmission.map(pyrisDTOService::toPyrisSubmissionDTO).orElse(null), - pyrisDTOService.toPyrisProgrammingExerciseDTO(exercise), new PyrisCourseDTO(exercise.getCourseViaExerciseGroupOrCourseMember()), base.chatHistory(), - base.user(), base.settings(), base.initialStages()), - () -> pyrisJobService.addExerciseChatJob(exercise.getCourseViaExerciseGroupOrCourseMember().getId(), exercise.getId(), session.getId())); + // @formatter:off + executePipeline( + "tutor-chat", // TODO: Rename this to 'exercise-chat' with next breaking Pyris version + variant, + pyrisJobService.addExerciseChatJob(exercise.getCourseViaExerciseGroupOrCourseMember().getId(), exercise.getId(), session.getId()), + executionDto -> { + var course = exercise.getCourseViaExerciseGroupOrCourseMember(); + return new PyrisExerciseChatPipelineExecutionDTO( + latestSubmission.map(pyrisDTOService::toPyrisSubmissionDTO).orElse(null), + pyrisDTOService.toPyrisProgrammingExerciseDTO(exercise), + new PyrisCourseDTO(course), + pyrisDTOService.toPyrisMessageDTOList(session.getMessages()), + new PyrisUserDTO(session.getUser()), + executionDto.settings(), + executionDto.initialStages() + ); + }, + stages -> irisChatWebsocketService.sendStatusUpdate(session, stages) + ); + // @formatter:on } /** @@ -178,17 +170,31 @@ public void executeExerciseChatPipeline(String variant, Optional { - var fullCourse = loadCourseWithParticipationOfStudent(courseId, studentId); - return new PyrisCourseChatPipelineExecutionDTO(PyrisExtendedCourseDTO.of(fullCourse), - learningMetricsService.getStudentCourseMetrics(session.getUser().getId(), courseId), competencyJol == null ? null : CompetencyJolDTO.of(competencyJol), - base.chatHistory(), base.user(), base.settings(), base.initialStages()); - }, () -> pyrisJobService.addCourseChatJob(courseId, session.getId())); + executePipeline( + "course-chat", + variant, + pyrisJobService.addCourseChatJob(courseId, session.getId()), + executionDto -> { + var fullCourse = loadCourseWithParticipationOfStudent(courseId, studentId); + return new PyrisCourseChatPipelineExecutionDTO( + PyrisExtendedCourseDTO.of(fullCourse), + learningMetricsService.getStudentCourseMetrics(session.getUser().getId(), courseId), + competencyJol == null ? null : CompetencyJolDTO.of(competencyJol), + pyrisDTOService.toPyrisMessageDTOList(session.getMessages()), + new PyrisUserDTO(session.getUser()), + executionDto.settings(), // flatten the execution dto here + executionDto.initialStages() + ); + }, + stages -> irisChatWebsocketService.sendStatusUpdate(session, stages) + ); + // @formatter:on } /** diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/PyrisStatusUpdateService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/PyrisStatusUpdateService.java index 759e62ca9e35..0966455b3533 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/PyrisStatusUpdateService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/PyrisStatusUpdateService.java @@ -1,17 +1,22 @@ package de.tum.in.www1.artemis.service.connectors.pyris; +import java.util.List; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; import de.tum.in.www1.artemis.service.connectors.pyris.dto.chat.PyrisChatStatusUpdateDTO; +import de.tum.in.www1.artemis.service.connectors.pyris.dto.competency.PyrisCompetencyStatusUpdateDTO; import de.tum.in.www1.artemis.service.connectors.pyris.dto.lectureingestionwebhook.PyrisLectureIngestionStatusUpdateDTO; import de.tum.in.www1.artemis.service.connectors.pyris.dto.status.PyrisStageDTO; import de.tum.in.www1.artemis.service.connectors.pyris.dto.status.PyrisStageState; +import de.tum.in.www1.artemis.service.connectors.pyris.job.CompetencyExtractionJob; import de.tum.in.www1.artemis.service.connectors.pyris.job.CourseChatJob; import de.tum.in.www1.artemis.service.connectors.pyris.job.ExerciseChatJob; import de.tum.in.www1.artemis.service.connectors.pyris.job.IngestionWebhookJob; +import de.tum.in.www1.artemis.service.iris.IrisCompetencyGenerationService; import de.tum.in.www1.artemis.service.iris.session.IrisCourseChatSessionService; import de.tum.in.www1.artemis.service.iris.session.IrisExerciseChatSessionService; @@ -25,13 +30,16 @@ public class PyrisStatusUpdateService { private final IrisCourseChatSessionService courseChatSessionService; + private final IrisCompetencyGenerationService competencyGenerationService; + private static final Logger log = LoggerFactory.getLogger(PyrisStatusUpdateService.class); public PyrisStatusUpdateService(PyrisJobService pyrisJobService, IrisExerciseChatSessionService irisExerciseChatSessionService, - IrisCourseChatSessionService courseChatSessionService) { + IrisCourseChatSessionService courseChatSessionService, IrisCompetencyGenerationService competencyGenerationService) { this.pyrisJobService = pyrisJobService; this.irisExerciseChatSessionService = irisExerciseChatSessionService; this.courseChatSessionService = courseChatSessionService; + this.competencyGenerationService = competencyGenerationService; } /** @@ -43,7 +51,7 @@ public PyrisStatusUpdateService(PyrisJobService pyrisJobService, IrisExerciseCha public void handleStatusUpdate(ExerciseChatJob job, PyrisChatStatusUpdateDTO statusUpdate) { irisExerciseChatSessionService.handleStatusUpdate(job, statusUpdate); - removeJobIfTerminated(statusUpdate, job.jobId()); + removeJobIfTerminated(statusUpdate.stages(), job.jobId()); } /** @@ -56,7 +64,20 @@ public void handleStatusUpdate(ExerciseChatJob job, PyrisChatStatusUpdateDTO sta public void handleStatusUpdate(CourseChatJob job, PyrisChatStatusUpdateDTO statusUpdate) { courseChatSessionService.handleStatusUpdate(job, statusUpdate); - removeJobIfTerminated(statusUpdate, job.jobId()); + removeJobIfTerminated(statusUpdate.stages(), job.jobId()); + } + + /** + * Handles the status update of a competency extraction job and forwards it to + * {@link de.tum.in.www1.artemis.service.iris.IrisCompetencyGenerationService#handleStatusUpdate(String, long, PyrisCompetencyStatusUpdateDTO)} + * + * @param job the job that is updated + * @param statusUpdate the status update + */ + public void handleStatusUpdate(CompetencyExtractionJob job, PyrisCompetencyStatusUpdateDTO statusUpdate) { + competencyGenerationService.handleStatusUpdate(job.userLogin(), job.courseId(), statusUpdate); + + removeJobIfTerminated(statusUpdate.stages(), job.jobId()); } /** @@ -66,11 +87,11 @@ public void handleStatusUpdate(CourseChatJob job, PyrisChatStatusUpdateDTO statu * * @see PyrisStageState#isTerminal() * - * @param statusUpdate the status update - * @param job the job to remove + * @param stages the stages of the status update + * @param job the job to remove */ - private void removeJobIfTerminated(PyrisChatStatusUpdateDTO statusUpdate, String job) { - var isDone = statusUpdate.stages().stream().map(PyrisStageDTO::state).allMatch(PyrisStageState::isTerminal); + private void removeJobIfTerminated(List stages, String job) { + var isDone = stages.stream().map(PyrisStageDTO::state).allMatch(PyrisStageState::isTerminal); if (isDone) { pyrisJobService.removeJob(job); } @@ -85,10 +106,6 @@ private void removeJobIfTerminated(PyrisChatStatusUpdateDTO statusUpdate, String */ public void handleStatusUpdate(IngestionWebhookJob job, PyrisLectureIngestionStatusUpdateDTO statusUpdate) { statusUpdate.stages().forEach(stage -> log.info(stage.name() + ":" + stage.message())); - boolean isDone = statusUpdate.stages().stream().map(PyrisStageDTO::state) - .allMatch(state -> state == PyrisStageState.DONE || state == PyrisStageState.ERROR || state == PyrisStageState.SKIPPED); - if (isDone) { - pyrisJobService.removeJob(job.jobId()); - } + removeJobIfTerminated(statusUpdate.stages(), job.jobId()); } } diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/dto/PyrisPipelineExecutionDTO.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/dto/PyrisPipelineExecutionDTO.java new file mode 100644 index 000000000000..86c5f01c0479 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/dto/PyrisPipelineExecutionDTO.java @@ -0,0 +1,11 @@ +package de.tum.in.www1.artemis.service.connectors.pyris.dto; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.in.www1.artemis.service.connectors.pyris.dto.status.PyrisStageDTO; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record PyrisPipelineExecutionDTO(PyrisPipelineExecutionSettingsDTO settings, List initialStages) { +} diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/dto/competency/PyrisCompetencyExtractionInputDTO.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/dto/competency/PyrisCompetencyExtractionInputDTO.java new file mode 100644 index 000000000000..504fe8aa92fa --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/dto/competency/PyrisCompetencyExtractionInputDTO.java @@ -0,0 +1,7 @@ +package de.tum.in.www1.artemis.service.connectors.pyris.dto.competency; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record PyrisCompetencyExtractionInputDTO(String courseDescription, PyrisCompetencyRecommendationDTO[] currentCompetencies) { +} diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/dto/competency/PyrisCompetencyExtractionPipelineExecutionDTO.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/dto/competency/PyrisCompetencyExtractionPipelineExecutionDTO.java new file mode 100644 index 000000000000..64979fa9125d --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/dto/competency/PyrisCompetencyExtractionPipelineExecutionDTO.java @@ -0,0 +1,20 @@ +package de.tum.in.www1.artemis.service.connectors.pyris.dto.competency; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.in.www1.artemis.domain.competency.CompetencyTaxonomy; +import de.tum.in.www1.artemis.service.connectors.pyris.dto.PyrisPipelineExecutionDTO; + +/** + * DTO to execute the Iris competency extraction pipeline on Pyris + * + * @param execution The pipeline execution details + * @param courseDescription The description of the course + * @param currentCompetencies The current competencies of the course (to avoid re-extraction) + * @param taxonomyOptions The taxonomy options to use + * @param maxN The maximum number of competencies to extract + */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record PyrisCompetencyExtractionPipelineExecutionDTO(PyrisPipelineExecutionDTO execution, String courseDescription, PyrisCompetencyRecommendationDTO[] currentCompetencies, + CompetencyTaxonomy[] taxonomyOptions, int maxN) { +} diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/dto/competency/PyrisCompetencyRecommendationDTO.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/dto/competency/PyrisCompetencyRecommendationDTO.java new file mode 100644 index 000000000000..897b9b7ca3ce --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/dto/competency/PyrisCompetencyRecommendationDTO.java @@ -0,0 +1,17 @@ +package de.tum.in.www1.artemis.service.connectors.pyris.dto.competency; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.in.www1.artemis.domain.competency.CompetencyTaxonomy; + +/** + * DTO for the Iris competency generation feature. + * A competency recommendation is just a title, description and taxonomy generated by Iris. + * + * @param title The title of the competency + * @param description The description of the competency + * @param taxonomy The taxonomy of the competency + */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record PyrisCompetencyRecommendationDTO(String title, String description, CompetencyTaxonomy taxonomy) { +} diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/dto/competency/PyrisCompetencyStatusUpdateDTO.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/dto/competency/PyrisCompetencyStatusUpdateDTO.java new file mode 100644 index 000000000000..967dfcd51906 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/dto/competency/PyrisCompetencyStatusUpdateDTO.java @@ -0,0 +1,19 @@ +package de.tum.in.www1.artemis.service.connectors.pyris.dto.competency; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.in.www1.artemis.service.connectors.pyris.dto.status.PyrisStageDTO; + +/** + * DTO for the Iris competency generation feature. + * Pyris sends callback updates back to Artemis during generation of competencies, + * which are then forwarded to the user via Websockets. + * + * @param stages List of stages of the generation process + * @param result List of competencies recommendations that have been generated so far + */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record PyrisCompetencyStatusUpdateDTO(List stages, List result) { +} diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/dto/status/PyrisStageDTO.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/dto/status/PyrisStageDTO.java index 39728eceb4a1..3bc3b2f9758a 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/dto/status/PyrisStageDTO.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/dto/status/PyrisStageDTO.java @@ -4,4 +4,25 @@ @JsonInclude(JsonInclude.Include.NON_EMPTY) public record PyrisStageDTO(String name, int weight, PyrisStageState state, String message) { + + public PyrisStageDTO notStarted() { + return new PyrisStageDTO(name, weight, PyrisStageState.NOT_STARTED, message); + } + + public PyrisStageDTO inProgress() { + return new PyrisStageDTO(name, weight, PyrisStageState.IN_PROGRESS, message); + } + + public PyrisStageDTO error(String message) { + return new PyrisStageDTO(name, weight, PyrisStageState.ERROR, message); + } + + public PyrisStageDTO done() { + return new PyrisStageDTO(name, weight, PyrisStageState.DONE, message); + } + + public PyrisStageDTO with(PyrisStageState state, String message) { + return new PyrisStageDTO(name, weight, state, message); + } + } diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/dto/status/PyrisStageState.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/dto/status/PyrisStageState.java index 10648687f7f2..0f2ace2f7e40 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/dto/status/PyrisStageState.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/dto/status/PyrisStageState.java @@ -5,15 +5,9 @@ @JsonInclude(JsonInclude.Include.NON_EMPTY) public enum PyrisStageState { - NOT_STARTED(false), IN_PROGRESS(false), DONE(true), SKIPPED(true), ERROR(true); - - private final boolean isTerminal; - - PyrisStageState(boolean isTerminal) { - this.isTerminal = isTerminal; - } + NOT_STARTED, IN_PROGRESS, DONE, SKIPPED, ERROR; public boolean isTerminal() { - return isTerminal; + return this == DONE || this == SKIPPED || this == ERROR; } } diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/job/CompetencyExtractionJob.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/job/CompetencyExtractionJob.java new file mode 100644 index 000000000000..2a76f5a3b072 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/job/CompetencyExtractionJob.java @@ -0,0 +1,22 @@ +package de.tum.in.www1.artemis.service.connectors.pyris.job; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.in.www1.artemis.domain.Course; + +/** + * A pyris job that extracts competencies from a course description. + * + * @param jobId the job id + * @param courseId the course in which the competencies are being extracted + * @param userLogin the user login of the user who started the job + */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record CompetencyExtractionJob(String jobId, long courseId, String userLogin) implements PyrisJob { + + @Override + public boolean canAccess(Course course) { + return course.getId().equals(courseId); + } + +} diff --git a/src/main/java/de/tum/in/www1/artemis/service/dto/UserDTO.java b/src/main/java/de/tum/in/www1/artemis/service/dto/UserDTO.java index 97d02ca20df7..22c2cccd6546 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/dto/UserDTO.java +++ b/src/main/java/de/tum/in/www1/artemis/service/dto/UserDTO.java @@ -74,6 +74,8 @@ public class UserDTO extends AuditingEntityDTO { private String vcsAccessToken; + private ZonedDateTime vcsAccessTokenExpiryDate; + private String sshPublicKey; private ZonedDateTime irisAccepted; @@ -250,6 +252,14 @@ public void setVcsAccessToken(String vcsAccessToken) { this.vcsAccessToken = vcsAccessToken; } + public void setVcsAccessTokenExpiryDate(ZonedDateTime zoneDateTime) { + this.vcsAccessTokenExpiryDate = zoneDateTime; + } + + public ZonedDateTime getVcsAccessTokenExpiryDate() { + return vcsAccessTokenExpiryDate; + } + public String getSshPublicKey() { return sshPublicKey; } diff --git a/src/main/java/de/tum/in/www1/artemis/service/iris/IrisCompetencyGenerationService.java b/src/main/java/de/tum/in/www1/artemis/service/iris/IrisCompetencyGenerationService.java new file mode 100644 index 000000000000..07be63259336 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/service/iris/IrisCompetencyGenerationService.java @@ -0,0 +1,71 @@ +package de.tum.in.www1.artemis.service.iris; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import de.tum.in.www1.artemis.domain.Course; +import de.tum.in.www1.artemis.domain.User; +import de.tum.in.www1.artemis.domain.competency.CompetencyTaxonomy; +import de.tum.in.www1.artemis.service.connectors.pyris.PyrisJobService; +import de.tum.in.www1.artemis.service.connectors.pyris.PyrisPipelineService; +import de.tum.in.www1.artemis.service.connectors.pyris.dto.competency.PyrisCompetencyExtractionPipelineExecutionDTO; +import de.tum.in.www1.artemis.service.connectors.pyris.dto.competency.PyrisCompetencyRecommendationDTO; +import de.tum.in.www1.artemis.service.connectors.pyris.dto.competency.PyrisCompetencyStatusUpdateDTO; +import de.tum.in.www1.artemis.service.connectors.pyris.job.CompetencyExtractionJob; +import de.tum.in.www1.artemis.service.iris.websocket.IrisWebsocketService; + +/** + * Service to handle the Competency generation subsytem of Iris. + */ +@Service +@Profile("iris") +public class IrisCompetencyGenerationService { + + private final PyrisPipelineService pyrisPipelineService; + + private final IrisWebsocketService websocketService; + + private final PyrisJobService pyrisJobService; + + public IrisCompetencyGenerationService(PyrisPipelineService pyrisPipelineService, IrisWebsocketService websocketService, PyrisJobService pyrisJobService) { + this.pyrisPipelineService = pyrisPipelineService; + this.websocketService = websocketService; + this.pyrisJobService = pyrisJobService; + } + + /** + * Executes the competency extraction pipeline on Pyris for a given course, user and course description + * + * @param user the user for which the pipeline should be executed + * @param course the course for which the pipeline should be executed + * @param courseDescription the description of the course + * @param currentCompetencies the current competencies of the course (to avoid re-extraction) + */ + public void executeCompetencyExtractionPipeline(User user, Course course, String courseDescription, PyrisCompetencyRecommendationDTO[] currentCompetencies) { + // @formatter:off + pyrisPipelineService.executePipeline( + "competency-extraction", + "default", + pyrisJobService.createTokenForJob(token -> new CompetencyExtractionJob(token, course.getId(), user.getLogin())), + executionDto -> new PyrisCompetencyExtractionPipelineExecutionDTO(executionDto, courseDescription, currentCompetencies, CompetencyTaxonomy.values(), 5), + stages -> websocketService.send(user.getLogin(), websocketTopic(course.getId()), new PyrisCompetencyStatusUpdateDTO(stages, null)) + ); + // @formatter:on + } + + /** + * Takes a status update from Pyris containing a new competency extraction result and sends it to the client via websocket + * + * @param userLogin the login of the user + * @param courseId the id of the course + * @param statusUpdate the status update containing the new competency recommendations + */ + public void handleStatusUpdate(String userLogin, long courseId, PyrisCompetencyStatusUpdateDTO statusUpdate) { + websocketService.send(userLogin, websocketTopic(courseId), statusUpdate); + } + + private static String websocketTopic(long courseId) { + return "competencies/" + courseId; + } + +} diff --git a/src/main/java/de/tum/in/www1/artemis/service/iris/IrisSessionService.java b/src/main/java/de/tum/in/www1/artemis/service/iris/IrisSessionService.java index dded30f4e42e..f455b13979fe 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/iris/IrisSessionService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/iris/IrisSessionService.java @@ -8,14 +8,12 @@ import de.tum.in.www1.artemis.domain.User; import de.tum.in.www1.artemis.domain.iris.message.IrisMessage; -import de.tum.in.www1.artemis.domain.iris.session.IrisCompetencyGenerationSession; import de.tum.in.www1.artemis.domain.iris.session.IrisCourseChatSession; import de.tum.in.www1.artemis.domain.iris.session.IrisExerciseChatSession; import de.tum.in.www1.artemis.domain.iris.session.IrisHestiaSession; import de.tum.in.www1.artemis.domain.iris.session.IrisSession; import de.tum.in.www1.artemis.repository.UserRepository; import de.tum.in.www1.artemis.service.iris.session.IrisChatBasedFeatureInterface; -import de.tum.in.www1.artemis.service.iris.session.IrisCompetencyGenerationSessionService; import de.tum.in.www1.artemis.service.iris.session.IrisCourseChatSessionService; import de.tum.in.www1.artemis.service.iris.session.IrisExerciseChatSessionService; import de.tum.in.www1.artemis.service.iris.session.IrisHestiaSessionService; @@ -38,16 +36,12 @@ public class IrisSessionService { private final IrisHestiaSessionService irisHestiaSessionService; - private final IrisCompetencyGenerationSessionService irisCompetencyGenerationSessionService; - public IrisSessionService(UserRepository userRepository, IrisExerciseChatSessionService irisExerciseChatSessionService, - IrisCourseChatSessionService irisCourseChatSessionService, IrisHestiaSessionService irisHestiaSessionService, - IrisCompetencyGenerationSessionService irisCompetencyGenerationSessionService) { + IrisCourseChatSessionService irisCourseChatSessionService, IrisHestiaSessionService irisHestiaSessionService) { this.userRepository = userRepository; this.irisExerciseChatSessionService = irisExerciseChatSessionService; this.irisCourseChatSessionService = irisCourseChatSessionService; this.irisHestiaSessionService = irisHestiaSessionService; - this.irisCompetencyGenerationSessionService = irisCompetencyGenerationSessionService; } /** @@ -107,7 +101,7 @@ public void requestMessageFromIris(S session) { public void sendOverWebsocket(IrisMessage message, S session) { var wrapper = getIrisSessionSubService(session); if (wrapper.irisSubFeatureInterface instanceof IrisChatBasedFeatureInterface chatWrapper) { - chatWrapper.sendOverWebsocket(message); + chatWrapper.sendOverWebsocket(session, message); } else { throw new BadRequestException("Invalid Iris session type " + message.getSession().getClass().getSimpleName()); @@ -143,8 +137,6 @@ private IrisSubFeatureWrapper getIrisSessionSubServic case IrisExerciseChatSession chatSession -> (IrisSubFeatureWrapper) new IrisSubFeatureWrapper<>(irisExerciseChatSessionService, chatSession); case IrisCourseChatSession courseChatSession -> (IrisSubFeatureWrapper) new IrisSubFeatureWrapper<>(irisCourseChatSessionService, courseChatSession); case IrisHestiaSession hestiaSession -> (IrisSubFeatureWrapper) new IrisSubFeatureWrapper<>(irisHestiaSessionService, hestiaSession); - case IrisCompetencyGenerationSession irisCompetencyGenerationSession -> - (IrisSubFeatureWrapper) new IrisSubFeatureWrapper<>(irisCompetencyGenerationSessionService, irisCompetencyGenerationSession); case null, default -> throw new BadRequestException("Unknown Iris session type " + session.getClass().getSimpleName()); }; } diff --git a/src/main/java/de/tum/in/www1/artemis/service/iris/websocket/IrisWebsocketDTO.java b/src/main/java/de/tum/in/www1/artemis/service/iris/dto/IrisChatWebsocketDTO.java similarity index 84% rename from src/main/java/de/tum/in/www1/artemis/service/iris/websocket/IrisWebsocketDTO.java rename to src/main/java/de/tum/in/www1/artemis/service/iris/dto/IrisChatWebsocketDTO.java index 4c5067bde279..12fa282a3012 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/iris/websocket/IrisWebsocketDTO.java +++ b/src/main/java/de/tum/in/www1/artemis/service/iris/dto/IrisChatWebsocketDTO.java @@ -1,4 +1,4 @@ -package de.tum.in.www1.artemis.service.iris.websocket; +package de.tum.in.www1.artemis.service.iris.dto; import java.util.List; import java.util.Objects; @@ -20,7 +20,7 @@ * @param stages the stages of the Pyris pipeline */ @JsonInclude(JsonInclude.Include.NON_EMPTY) -public record IrisWebsocketDTO(IrisWebsocketMessageType type, IrisMessage message, IrisRateLimitService.IrisRateLimitInformation rateLimitInfo, List stages, +public record IrisChatWebsocketDTO(IrisWebsocketMessageType type, IrisMessage message, IrisRateLimitService.IrisRateLimitInformation rateLimitInfo, List stages, List suggestions) { /** @@ -31,7 +31,7 @@ public record IrisWebsocketDTO(IrisWebsocketMessageType type, IrisMessage messag * @param rateLimitInfo the rate limit information * @param stages the stages of the Pyris pipeline */ - public IrisWebsocketDTO(@Nullable IrisMessage message, IrisRateLimitService.IrisRateLimitInformation rateLimitInfo, List stages, List suggestions) { + public IrisChatWebsocketDTO(@Nullable IrisMessage message, IrisRateLimitService.IrisRateLimitInformation rateLimitInfo, List stages, List suggestions) { this(determineType(message), message, rateLimitInfo, stages, suggestions); } @@ -62,7 +62,7 @@ public boolean equals(Object o) { if (o == null || getClass() != o.getClass()) { return false; } - IrisWebsocketDTO that = (IrisWebsocketDTO) o; + IrisChatWebsocketDTO that = (IrisChatWebsocketDTO) o; return type == that.type && Objects.equals(message, that.message) && Objects.equals(rateLimitInfo, that.rateLimitInfo) && Objects.equals(stages, that.stages); } diff --git a/src/main/java/de/tum/in/www1/artemis/service/iris/dto/IrisCombinedChatSubSettingsDTO.java b/src/main/java/de/tum/in/www1/artemis/service/iris/dto/IrisCombinedChatSubSettingsDTO.java index 2bef37bda870..c98fdb1663a0 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/iris/dto/IrisCombinedChatSubSettingsDTO.java +++ b/src/main/java/de/tum/in/www1/artemis/service/iris/dto/IrisCombinedChatSubSettingsDTO.java @@ -10,6 +10,6 @@ @JsonInclude(JsonInclude.Include.NON_EMPTY) public record IrisCombinedChatSubSettingsDTO(boolean enabled, Integer rateLimit, Integer rateLimitTimeframeHours, @Nullable Set allowedModels, - @Nullable String preferredModel, @Nullable IrisTemplate template) implements IrisCombinedSubSettingsInterface { + @Nullable String preferredModel, @Nullable IrisTemplate template) { } diff --git a/src/main/java/de/tum/in/www1/artemis/service/iris/dto/IrisCombinedCompetencyGenerationSubSettingsDTO.java b/src/main/java/de/tum/in/www1/artemis/service/iris/dto/IrisCombinedCompetencyGenerationSubSettingsDTO.java index 7e2e399e530e..e924be92e773 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/iris/dto/IrisCombinedCompetencyGenerationSubSettingsDTO.java +++ b/src/main/java/de/tum/in/www1/artemis/service/iris/dto/IrisCombinedCompetencyGenerationSubSettingsDTO.java @@ -9,7 +9,6 @@ import de.tum.in.www1.artemis.domain.iris.IrisTemplate; @JsonInclude(JsonInclude.Include.NON_EMPTY) -public record IrisCombinedCompetencyGenerationSubSettingsDTO(boolean enabled, @Nullable Set allowedModels, @Nullable String preferredModel, @Nullable IrisTemplate template) - implements IrisCombinedSubSettingsInterface { - +public record IrisCombinedCompetencyGenerationSubSettingsDTO(boolean enabled, @Nullable Set allowedModels, @Nullable String preferredModel, + @Nullable IrisTemplate template) { } diff --git a/src/main/java/de/tum/in/www1/artemis/service/iris/dto/IrisCombinedHestiaSubSettingsDTO.java b/src/main/java/de/tum/in/www1/artemis/service/iris/dto/IrisCombinedHestiaSubSettingsDTO.java index 47861b16ebbb..315fb572194c 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/iris/dto/IrisCombinedHestiaSubSettingsDTO.java +++ b/src/main/java/de/tum/in/www1/artemis/service/iris/dto/IrisCombinedHestiaSubSettingsDTO.java @@ -9,6 +9,5 @@ import de.tum.in.www1.artemis.domain.iris.IrisTemplate; @JsonInclude(JsonInclude.Include.NON_EMPTY) -public record IrisCombinedHestiaSubSettingsDTO(boolean enabled, @Nullable Set allowedModels, @Nullable String preferredModel, @Nullable IrisTemplate template) - implements IrisCombinedSubSettingsInterface { +public record IrisCombinedHestiaSubSettingsDTO(boolean enabled, @Nullable Set allowedModels, @Nullable String preferredModel, @Nullable IrisTemplate template) { } diff --git a/src/main/java/de/tum/in/www1/artemis/service/iris/dto/IrisCombinedSubSettingsInterface.java b/src/main/java/de/tum/in/www1/artemis/service/iris/dto/IrisCombinedSubSettingsInterface.java deleted file mode 100644 index 79d23120b4fc..000000000000 --- a/src/main/java/de/tum/in/www1/artemis/service/iris/dto/IrisCombinedSubSettingsInterface.java +++ /dev/null @@ -1,16 +0,0 @@ -package de.tum.in.www1.artemis.service.iris.dto; - -import java.util.Set; - -import jakarta.annotation.Nullable; - -public interface IrisCombinedSubSettingsInterface { - - boolean enabled(); - - @Nullable - Set allowedModels(); - - @Nullable - String preferredModel(); -} diff --git a/src/main/java/de/tum/in/www1/artemis/service/iris/session/IrisChatBasedFeatureInterface.java b/src/main/java/de/tum/in/www1/artemis/service/iris/session/IrisChatBasedFeatureInterface.java index db345e973fdb..2001cb7077b8 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/iris/session/IrisChatBasedFeatureInterface.java +++ b/src/main/java/de/tum/in/www1/artemis/service/iris/session/IrisChatBasedFeatureInterface.java @@ -8,9 +8,10 @@ public interface IrisChatBasedFeatureInterface extends Ir /** * Sends a message over the websocket to a specific user * + * @param session that the message belongs to * @param message that should be sent over the websocket */ - void sendOverWebsocket(IrisMessage message); + void sendOverWebsocket(S session, IrisMessage message); /** * Sends a request to Iris to get a message for the given session. diff --git a/src/main/java/de/tum/in/www1/artemis/service/iris/session/IrisCompetencyGenerationSessionService.java b/src/main/java/de/tum/in/www1/artemis/service/iris/session/IrisCompetencyGenerationSessionService.java deleted file mode 100644 index d8ec1f901123..000000000000 --- a/src/main/java/de/tum/in/www1/artemis/service/iris/session/IrisCompetencyGenerationSessionService.java +++ /dev/null @@ -1,154 +0,0 @@ -package de.tum.in.www1.artemis.service.iris.session; - -import java.time.ZonedDateTime; -import java.util.ArrayList; -import java.util.List; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.context.annotation.Profile; -import org.springframework.stereotype.Service; - -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.databind.JsonNode; - -import de.tum.in.www1.artemis.domain.Course; -import de.tum.in.www1.artemis.domain.User; -import de.tum.in.www1.artemis.domain.competency.Competency; -import de.tum.in.www1.artemis.domain.competency.CompetencyTaxonomy; -import de.tum.in.www1.artemis.domain.competency.CourseCompetency; -import de.tum.in.www1.artemis.domain.iris.message.IrisMessage; -import de.tum.in.www1.artemis.domain.iris.message.IrisMessageSender; -import de.tum.in.www1.artemis.domain.iris.message.IrisTextMessageContent; -import de.tum.in.www1.artemis.domain.iris.session.IrisCompetencyGenerationSession; -import de.tum.in.www1.artemis.domain.iris.settings.IrisSubSettingsType; -import de.tum.in.www1.artemis.repository.iris.IrisCompetencyGenerationSessionRepository; -import de.tum.in.www1.artemis.repository.iris.IrisMessageRepository; -import de.tum.in.www1.artemis.repository.iris.IrisSessionRepository; -import de.tum.in.www1.artemis.security.Role; -import de.tum.in.www1.artemis.service.AuthorizationCheckService; -import de.tum.in.www1.artemis.service.connectors.pyris.PyrisConnectorService; -import de.tum.in.www1.artemis.service.iris.IrisMessageService; -import de.tum.in.www1.artemis.service.iris.settings.IrisSettingsService; - -/** - * Service to handle the Competency generation subsytem of Iris. - */ -@Service -@Profile("iris") -public class IrisCompetencyGenerationSessionService implements IrisButtonBasedFeatureInterface> { - - private static final Logger log = LoggerFactory.getLogger(IrisCompetencyGenerationSessionService.class); - - private final PyrisConnectorService pyrisConnectorService; - - private final IrisSettingsService irisSettingsService; - - private final IrisSessionRepository irisSessionRepository; - - private final AuthorizationCheckService authCheckService; - - private final IrisCompetencyGenerationSessionRepository irisCompetencyGenerationSessionRepository; - - private final IrisMessageService irisMessageService; - - private final IrisMessageRepository irisMessageRepository; - - public IrisCompetencyGenerationSessionService(PyrisConnectorService pyrisConnectorService, IrisSettingsService irisSettingsService, IrisSessionRepository irisSessionRepository, - AuthorizationCheckService authCheckService, IrisCompetencyGenerationSessionRepository irisCompetencyGenerationSessionRepository, IrisMessageService irisMessageService, - IrisMessageRepository irisMessageRepository) { - this.pyrisConnectorService = pyrisConnectorService; - this.irisSettingsService = irisSettingsService; - this.irisSessionRepository = irisSessionRepository; - this.authCheckService = authCheckService; - this.irisCompetencyGenerationSessionRepository = irisCompetencyGenerationSessionRepository; - this.irisMessageService = irisMessageService; - this.irisMessageRepository = irisMessageRepository; - } - - /** - * Creates a new Iris session for the given course and user or gets an existing one from the last hour. - * - * @param course The course to create the session for - * @param user The user to create the session for - * @return The Iris session for the course - */ - public IrisCompetencyGenerationSession getOrCreateSession(Course course, User user) { - var existingSession = irisCompetencyGenerationSessionRepository.findFirstByCourseIdAndUserIdOrderByCreationDateDesc(course.getId(), user.getId()); - // Return the newest session if there is one and it is not older than 1 hour - if (existingSession != null && existingSession.getCreationDate().plusHours(1).isAfter(ZonedDateTime.now())) { - checkHasAccessTo(user, existingSession); - checkIsFeatureActivatedFor(existingSession); - return existingSession; - } - - var irisSession = new IrisCompetencyGenerationSession(); - irisSession.setCourse(course); - irisSession.setUser(user); - checkHasAccessTo(user, irisSession); - checkIsFeatureActivatedFor(irisSession); - irisSession = irisSessionRepository.save(irisSession); - return irisSession; - } - - /** - * Adds a user text message to a given IRIS session - * - * @param session the IRIS session - * @param message the message to add - */ - public void addUserTextMessageToSession(IrisCompetencyGenerationSession session, String message) { - var userMessage = new IrisMessage(); - userMessage.setSender(IrisMessageSender.USER); - userMessage.addContent(new IrisTextMessageContent(message)); - irisMessageService.saveMessage(userMessage, session, IrisMessageSender.USER); - } - - // @formatter:off - @JsonInclude(JsonInclude.Include.NON_EMPTY) - record CompetencyGenerationDTO( - String courseDescription, - CompetencyTaxonomy[] taxonomyOptions - ) {} - // @formatter:on - - @Override - public List executeRequest(IrisCompetencyGenerationSession session) { - // TODO: Re-add in a future PR. Remember to reenable the test cases! - return null; - } - - @Override - public void checkHasAccessTo(User user, IrisCompetencyGenerationSession irisSession) { - authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.EDITOR, irisSession.getCourse(), user); - } - - @Override - public void checkIsFeatureActivatedFor(IrisCompetencyGenerationSession irisSession) { - irisSettingsService.isEnabledForElseThrow(IrisSubSettingsType.COMPETENCY_GENERATION, irisSession.getCourse()); - } - - private List toCompetencies(JsonNode content) { - List competencies = new ArrayList<>(); - for (JsonNode node : content.get("competencies")) { - try { - Competency competency = new Competency(); - competency.setTitle(node.required("title").asText()); - - // skip competency if IRIS only replied with a title containing the special response "!done!" - if (node.get("description") == null && node.get("title").asText().equals("!done!")) { - log.info("Received special response \"!done!\", skipping parsing of competency."); - continue; - } - competency.setDescription(node.required("description").asText()); - competency.setTaxonomy(CompetencyTaxonomy.valueOf(node.required("taxonomy").asText())); - - competencies.add(competency); - } - catch (IllegalArgumentException e) { - log.error("Missing fields, could not parse Competency: " + node.toPrettyString(), e); - } - } - return competencies; - } -} diff --git a/src/main/java/de/tum/in/www1/artemis/service/iris/session/IrisCourseChatSessionService.java b/src/main/java/de/tum/in/www1/artemis/service/iris/session/IrisCourseChatSessionService.java index 134e47b2abc4..bd5a4c25887b 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/iris/session/IrisCourseChatSessionService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/iris/session/IrisCourseChatSessionService.java @@ -97,8 +97,8 @@ public void checkIsFeatureActivatedFor(IrisCourseChatSession session) { } @Override - public void sendOverWebsocket(IrisMessage message) { - irisChatWebsocketService.sendMessage(message); + public void sendOverWebsocket(IrisCourseChatSession session, IrisMessage message) { + irisChatWebsocketService.sendMessage(session, message, null); } @Override @@ -135,7 +135,7 @@ public void handleStatusUpdate(CourseChatJob job, PyrisChatStatusUpdateDTO statu var message = new IrisMessage(); message.addContent(new IrisTextMessageContent(statusUpdate.result())); var savedMessage = irisMessageService.saveMessage(message, session, IrisMessageSender.LLM); - irisChatWebsocketService.sendMessage(savedMessage, statusUpdate.stages()); + irisChatWebsocketService.sendMessage(session, savedMessage, statusUpdate.stages()); } else { irisChatWebsocketService.sendStatusUpdate(session, statusUpdate.stages(), statusUpdate.suggestions()); diff --git a/src/main/java/de/tum/in/www1/artemis/service/iris/session/IrisExerciseChatSessionService.java b/src/main/java/de/tum/in/www1/artemis/service/iris/session/IrisExerciseChatSessionService.java index 2916a34171ab..36508d8b0934 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/iris/session/IrisExerciseChatSessionService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/iris/session/IrisExerciseChatSessionService.java @@ -119,8 +119,8 @@ public void checkIsFeatureActivatedFor(IrisExerciseChatSession session) { } @Override - public void sendOverWebsocket(IrisMessage message) { - irisChatWebsocketService.sendMessage(message); + public void sendOverWebsocket(IrisExerciseChatSession session, IrisMessage message) { + irisChatWebsocketService.sendMessage(session, message, null); } @Override @@ -170,7 +170,7 @@ public void handleStatusUpdate(ExerciseChatJob job, PyrisChatStatusUpdateDTO sta var message = new IrisMessage(); message.addContent(new IrisTextMessageContent(statusUpdate.result())); var savedMessage = irisMessageService.saveMessage(message, session, IrisMessageSender.LLM); - irisChatWebsocketService.sendMessage(savedMessage, statusUpdate.stages()); + irisChatWebsocketService.sendMessage(session, savedMessage, statusUpdate.stages()); } else { irisChatWebsocketService.sendStatusUpdate(session, statusUpdate.stages(), statusUpdate.suggestions()); diff --git a/src/main/java/de/tum/in/www1/artemis/service/iris/websocket/IrisChatWebsocketService.java b/src/main/java/de/tum/in/www1/artemis/service/iris/websocket/IrisChatWebsocketService.java index 253ba0df1e45..21bfc4449228 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/iris/websocket/IrisChatWebsocketService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/iris/websocket/IrisChatWebsocketService.java @@ -5,52 +5,42 @@ import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; -import de.tum.in.www1.artemis.domain.User; import de.tum.in.www1.artemis.domain.iris.message.IrisMessage; import de.tum.in.www1.artemis.domain.iris.session.IrisChatSession; -import de.tum.in.www1.artemis.domain.iris.session.IrisSession; -import de.tum.in.www1.artemis.service.WebsocketMessagingService; import de.tum.in.www1.artemis.service.connectors.pyris.dto.status.PyrisStageDTO; import de.tum.in.www1.artemis.service.iris.IrisRateLimitService; +import de.tum.in.www1.artemis.service.iris.dto.IrisChatWebsocketDTO; @Service @Profile("iris") -public class IrisChatWebsocketService extends IrisWebsocketService { +public class IrisChatWebsocketService { + + private final IrisWebsocketService websocketService; private final IrisRateLimitService rateLimitService; - public IrisChatWebsocketService(WebsocketMessagingService websocketMessagingService, IrisRateLimitService rateLimitService) { - super(websocketMessagingService); + public IrisChatWebsocketService(IrisWebsocketService websocketService, IrisRateLimitService rateLimitService) { + this.websocketService = websocketService; this.rateLimitService = rateLimitService; } - private User checkSessionTypeAndGetUser(IrisSession irisSession) { - if (!(irisSession instanceof IrisChatSession chatSession)) { - throw new UnsupportedOperationException("Only IrisChatSessions are supported"); - } - return chatSession.getUser(); - } - /** - * Sends a message over the websocket to a specific user + * Sends a message and/or a status update over the websocket to the user + * involved in the session. At least one of the message or the stages must be + * non-null, otherwise there is no need to send a message. + * This is currently used for both the exercise and course chat sessions, but + * this could be split up in the future. * + * @param session the session to send the message to * @param irisMessage that should be sent over the websocket * @param stages that should be sent over the websocket */ - public void sendMessage(IrisMessage irisMessage, List stages) { - var session = irisMessage.getSession(); - var user = checkSessionTypeAndGetUser(session); + public void sendMessage(IrisChatSession session, IrisMessage irisMessage, List stages) { + var user = session.getUser(); var rateLimitInfo = rateLimitService.getRateLimitInformation(user); - super.send(user, session.getId(), new IrisWebsocketDTO(irisMessage, rateLimitInfo, stages, null)); - } - - /** - * Sends a message over the websocket to a specific user without stages and suggestions - * - * @param message that should be sent over the websocket - */ - public void sendMessage(IrisMessage message) { - sendMessage(message, null); + var topic = "" + session.getId(); // Todo: add more specific topic + var payload = new IrisChatWebsocketDTO(irisMessage, rateLimitInfo, stages, null); + websocketService.send(user.getLogin(), topic, payload); } /** @@ -59,7 +49,7 @@ public void sendMessage(IrisMessage message) { * @param session the session to send the status update to * @param stages the stages to send */ - public void sendStatusUpdate(IrisSession session, List stages) { + public void sendStatusUpdate(IrisChatSession session, List stages) { this.sendStatusUpdate(session, stages, null); } @@ -70,8 +60,11 @@ public void sendStatusUpdate(IrisSession session, List stages) { * @param stages the stages to send * @param suggestions the suggestions to send */ - public void sendStatusUpdate(IrisSession session, List stages, List suggestions) { - var user = checkSessionTypeAndGetUser(session); - super.send(user, session.getId(), new IrisWebsocketDTO(null, rateLimitService.getRateLimitInformation(user), stages, suggestions)); + public void sendStatusUpdate(IrisChatSession session, List stages, List suggestions) { + var user = session.getUser(); + var rateLimitInfo = rateLimitService.getRateLimitInformation(user); + var topic = "" + session.getId(); // Todo: add more specific topic + var payload = new IrisChatWebsocketDTO(null, rateLimitInfo, stages, suggestions); + websocketService.send(user.getLogin(), topic, payload); } } diff --git a/src/main/java/de/tum/in/www1/artemis/service/iris/websocket/IrisWebsocketService.java b/src/main/java/de/tum/in/www1/artemis/service/iris/websocket/IrisWebsocketService.java index 537d9f31ec76..3fcd949be7b8 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/iris/websocket/IrisWebsocketService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/iris/websocket/IrisWebsocketService.java @@ -1,19 +1,24 @@ package de.tum.in.www1.artemis.service.iris.websocket; +import java.util.concurrent.ExecutionException; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; -import de.tum.in.www1.artemis.domain.User; import de.tum.in.www1.artemis.service.WebsocketMessagingService; /** * A service to send a message over the websocket to a specific user */ -public abstract class IrisWebsocketService { +@Service +@Profile("iris") +public class IrisWebsocketService { private static final Logger log = LoggerFactory.getLogger(IrisWebsocketService.class); - private static final String IRIS_WEBSOCKET_TOPIC_PREFIX = "/topic/iris"; + private static final String TOPIC_PREFIX = "/topic/iris/"; private final WebsocketMessagingService websocketMessagingService; @@ -21,10 +26,22 @@ public IrisWebsocketService(WebsocketMessagingService websocketMessagingService) this.websocketMessagingService = websocketMessagingService; } - protected void send(User user, Long sessionId, Object payload) { - String irisWebsocketTopic = String.format("%s/%s", IRIS_WEBSOCKET_TOPIC_PREFIX, sessionId); - log.debug("Sending message to user {} on topic {}: {}", user.getLogin(), irisWebsocketTopic, payload); - websocketMessagingService.sendMessageToUser(user.getLogin(), irisWebsocketTopic, payload); + /** + * Sends a message over the websocket to a specific user + * + * @param userLogin the login of the user + * @param topicSuffix the suffix of the topic, which will be appended to "/topic/iris/" + * @param payload the DTO to send, which will be serialized to JSON + */ + public void send(String userLogin, String topicSuffix, Object payload) { + String topic = TOPIC_PREFIX + topicSuffix; + try { + websocketMessagingService.sendMessageToUser(userLogin, topic, payload).get(); + log.debug("Sent message to Iris user {} on topic {}: {}", userLogin, topic, payload); + } + catch (InterruptedException | ExecutionException e) { + log.error("Error while sending message to Iris user {} on topic {}: {}", userLogin, topic, payload, e); + } } } diff --git a/src/main/java/de/tum/in/www1/artemis/service/programming/TemplateUpgradePolicyService.java b/src/main/java/de/tum/in/www1/artemis/service/programming/TemplateUpgradePolicyService.java index ad6ae2ab5744..a836f81a4982 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/programming/TemplateUpgradePolicyService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/programming/TemplateUpgradePolicyService.java @@ -32,8 +32,8 @@ public TemplateUpgradePolicyService(JavaTemplateUpgradeService javaRepositoryUpg public TemplateUpgradeService getUpgradeService(ProgrammingLanguage programmingLanguage) { return switch (programmingLanguage) { case JAVA -> javaRepositoryUpgradeService; - case KOTLIN, PYTHON, C, HASKELL, VHDL, ASSEMBLER, SWIFT, OCAML, EMPTY -> defaultRepositoryUpgradeService; - case JAVASCRIPT, C_SHARP, C_PLUS_PLUS, SQL, R, TYPESCRIPT, RUST, GO, MATLAB, BASH, RUBY, POWERSHELL, ADA, DART, PHP -> + case KOTLIN, PYTHON, C, HASKELL, VHDL, ASSEMBLER, SWIFT, OCAML, EMPTY, RUST -> defaultRepositoryUpgradeService; + case JAVASCRIPT, C_SHARP, C_PLUS_PLUS, SQL, R, TYPESCRIPT, GO, MATLAB, BASH, RUBY, POWERSHELL, ADA, DART, PHP -> throw new UnsupportedOperationException("Unsupported programming language: " + programmingLanguage); }; } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/AccountResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/AccountResource.java index 29ba05bcd377..5734c258da9a 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/AccountResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/AccountResource.java @@ -2,27 +2,42 @@ import static de.tum.in.www1.artemis.config.Constants.PROFILE_CORE; +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.security.PublicKey; +import java.time.ZonedDateTime; import java.util.Optional; import jakarta.validation.Valid; +import jakarta.ws.rs.BadRequestException; +import org.apache.sshd.common.config.keys.AuthorizedKeyEntry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import de.tum.in.www1.artemis.config.icl.ssh.HashUtils; import de.tum.in.www1.artemis.domain.User; import de.tum.in.www1.artemis.repository.UserRepository; import de.tum.in.www1.artemis.security.annotations.EnforceAtLeastStudent; import de.tum.in.www1.artemis.service.AccountService; +import de.tum.in.www1.artemis.service.connectors.localvc.LocalVCPersonalAccessTokenManagementService; import de.tum.in.www1.artemis.service.dto.PasswordChangeDTO; import de.tum.in.www1.artemis.service.dto.UserDTO; import de.tum.in.www1.artemis.service.user.UserCreationService; import de.tum.in.www1.artemis.service.user.UserService; import de.tum.in.www1.artemis.web.rest.errors.AccessForbiddenException; +import de.tum.in.www1.artemis.web.rest.errors.BadRequestAlertException; import de.tum.in.www1.artemis.web.rest.errors.EmailAlreadyUsedException; import de.tum.in.www1.artemis.web.rest.errors.PasswordViolatesRequirementsException; @@ -34,6 +49,11 @@ @RequestMapping("api/") public class AccountResource { + @Value("${jhipster.clientApp.name}") + private String applicationName; + + private static final Logger log = LoggerFactory.getLogger(AccountResource.class); + private final UserRepository userRepository; private final UserService userService; @@ -96,4 +116,120 @@ public ResponseEntity changePassword(@RequestBody PasswordChangeDTO passwo return ResponseEntity.ok().build(); } + + /** + * PUT account/ssh-public-key : sets the ssh public key + * + * @param sshPublicKey the ssh public key to set + * + * @return the ResponseEntity with status 200 (OK), with status 404 (Not Found), or with status 400 (Bad Request) + */ + @PutMapping("account/ssh-public-key") + @EnforceAtLeastStudent + public ResponseEntity addSshPublicKey(@RequestBody String sshPublicKey) throws GeneralSecurityException, IOException { + + User user = userRepository.getUser(); + log.debug("REST request to add SSH key to user {}", user.getLogin()); + // Parse the public key string + AuthorizedKeyEntry keyEntry; + try { + keyEntry = AuthorizedKeyEntry.parseAuthorizedKeyEntry(sshPublicKey); + } + catch (IllegalArgumentException e) { + throw new BadRequestAlertException("Invalid SSH key format", "SSH key", "invalidKeyFormat", true); + } + // Extract the PublicKey object + PublicKey publicKey = keyEntry.resolvePublicKey(null, null, null); + String keyHash = HashUtils.getSha512Fingerprint(publicKey); + userRepository.updateUserSshPublicKeyHash(user.getId(), keyHash, sshPublicKey); + return ResponseEntity.ok().build(); + } + + /** + * PUT account/ssh-public-key : sets the ssh public key + * + * @return the ResponseEntity with status 200 (OK), with status 404 (Not Found), or with status 400 (Bad Request) + */ + @DeleteMapping("account/ssh-public-key") + @EnforceAtLeastStudent + public ResponseEntity deleteSshPublicKey() { + User user = userRepository.getUser(); + log.debug("REST request to remove SSH key of user {}", user.getLogin()); + userRepository.updateUserSshPublicKeyHash(user.getId(), null, null); + + log.debug("Successfully deleted SSH key of user {}", user.getLogin()); + return ResponseEntity.ok().build(); + } + + /** + * PUT account/user-vcs-access-token : creates a vcsAccessToken for a user + * + * @param expiryDate The expiry date which should be set for the token + * @return the ResponseEntity with a userDTO containing the token: with status 200 (OK), with status 404 (Not Found), or with status 400 (Bad Request) + */ + @PutMapping("account/user-vcs-access-token") + @EnforceAtLeastStudent + public ResponseEntity createVcsAccessToken(@RequestParam("expiryDate") ZonedDateTime expiryDate) { + User user = userRepository.getUser(); + log.debug("REST request to create a new VCS access token for user {}", user.getLogin()); + if (expiryDate.isBefore(ZonedDateTime.now()) || expiryDate.isAfter(ZonedDateTime.now().plusYears(1))) { + throw new BadRequestException("Invalid expiry date provided"); + } + + userRepository.updateUserVcsAccessToken(user.getId(), LocalVCPersonalAccessTokenManagementService.generateSecureVCSAccessToken(), expiryDate); + log.debug("Successfully created a VCS access token for user {}", user.getLogin()); + user = userRepository.getUser(); + UserDTO userDTO = new UserDTO(); + userDTO.setLogin(user.getLogin()); + userDTO.setVcsAccessToken(user.getVcsAccessToken()); + userDTO.setVcsAccessTokenExpiryDate(user.getVcsAccessTokenExpiryDate()); + return ResponseEntity.ok(userDTO); + } + + /** + * DELETE account/user-vcs-access-token : deletes the vcsAccessToken of a user + * + * @return the ResponseEntity with status 200 (OK), with status 404 (Not Found), or with status 400 (Bad Request) + */ + @DeleteMapping("account/user-vcs-access-token") + @EnforceAtLeastStudent + public ResponseEntity deleteVcsAccessToken() { + User user = userRepository.getUser(); + log.debug("REST request to remove VCS access token key of user {}", user.getLogin()); + userRepository.updateUserVcsAccessToken(user.getId(), null, null); + log.debug("Successfully deleted VCS access token of user {}", user.getLogin()); + return ResponseEntity.ok().build(); + } + + /** + * GET account/participation-vcs-access-token : get the vcsToken for of a user for a participation + * + * @param participationId the participation for which the access token should be fetched + * + * @return the versionControlAccessToken belonging to the provided participation and user + */ + @GetMapping("account/participation-vcs-access-token") + @EnforceAtLeastStudent + public ResponseEntity getVcsAccessToken(@RequestParam("participationId") Long participationId) { + User user = userRepository.getUser(); + + log.debug("REST request to get VCS access token of user {} for participation {}", user.getLogin(), participationId); + return ResponseEntity.ok(userService.getParticipationVcsAccessTokenForUserAndParticipationIdOrElseThrow(user, participationId).getVcsAccessToken()); + } + + /** + * PUT account/participation-vcs-access-token : get the vcsToken for of a user for a participation + * + * @param participationId the participation for which the access token should be fetched + * + * @return the versionControlAccessToken belonging to the provided participation and user + */ + @PutMapping("account/participation-vcs-access-token") + @EnforceAtLeastStudent + public ResponseEntity createVcsAccessToken(@RequestParam("participationId") Long participationId) { + User user = userRepository.getUser(); + + log.debug("REST request to create a new VCS access token for user {} for participation {}", user.getLogin(), participationId); + return ResponseEntity.ok(userService.createParticipationVcsAccessTokenForUserAndParticipationIdOrElseThrow(user, participationId).getVcsAccessToken()); + } } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/UserResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/UserResource.java index 747208042837..8415cbc6f104 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/UserResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/UserResource.java @@ -2,24 +2,18 @@ import static de.tum.in.www1.artemis.config.Constants.PROFILE_CORE; -import java.io.IOException; -import java.security.GeneralSecurityException; -import java.security.PublicKey; import java.time.ZonedDateTime; import java.util.List; import java.util.Optional; -import org.apache.sshd.common.config.keys.AuthorizedKeyEntry; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -29,7 +23,6 @@ import org.springframework.web.server.ResponseStatusException; import org.springframework.web.servlet.support.ServletUriComponentsBuilder; -import de.tum.in.www1.artemis.config.icl.ssh.HashUtils; import de.tum.in.www1.artemis.domain.User; import de.tum.in.www1.artemis.repository.UserRepository; import de.tum.in.www1.artemis.security.annotations.EnforceAtLeastInstructor; @@ -39,7 +32,6 @@ import de.tum.in.www1.artemis.service.dto.UserInitializationDTO; import de.tum.in.www1.artemis.service.user.UserCreationService; import de.tum.in.www1.artemis.service.user.UserService; -import de.tum.in.www1.artemis.web.rest.util.HeaderUtil; import tech.jhipster.web.util.PaginationUtil; /** @@ -67,9 +59,6 @@ @RequestMapping("api/") public class UserResource { - @Value("${jhipster.clientApp.name}") - private String applicationName; - private static final Logger log = LoggerFactory.getLogger(UserResource.class); private final UserService userService; @@ -180,81 +169,4 @@ public ResponseEntity setIrisAcceptedToTimestamp() { userRepository.updateIrisAcceptedToDate(user.getId(), ZonedDateTime.now()); return ResponseEntity.ok().build(); } - - /** - * PUT users/sshpublickey : sets the ssh public key - * - * @param sshPublicKey the ssh public key to set - * - * @return the ResponseEntity with status 200 (OK), with status 404 (Not Found), or with status 400 (Bad Request) - */ - @PutMapping("users/sshpublickey") - @EnforceAtLeastStudent - public ResponseEntity addSshPublicKey(@RequestBody String sshPublicKey) throws GeneralSecurityException, IOException { - - User user = userRepository.getUser(); - log.debug("REST request to add SSH key to user {}", user.getLogin()); - // Parse the public key string - AuthorizedKeyEntry keyEntry; - try { - keyEntry = AuthorizedKeyEntry.parseAuthorizedKeyEntry(sshPublicKey); - } - catch (IllegalArgumentException e) { - return ResponseEntity.badRequest().headers(HeaderUtil.createFailureAlert(applicationName, true, "sshUserSettings", "saveSshKeyError", "Invalid SSH key format")) - .body(null); - } - // Extract the PublicKey object - PublicKey publicKey = keyEntry.resolvePublicKey(null, null, null); - String keyHash = HashUtils.getSha512Fingerprint(publicKey); - userRepository.updateUserSshPublicKeyHash(user.getId(), keyHash, sshPublicKey); - return ResponseEntity.ok().build(); - } - - /** - * PUT users/sshpublickey : sets the ssh public key - * - * @return the ResponseEntity with status 200 (OK), with status 404 (Not Found), or with status 400 (Bad Request) - */ - @DeleteMapping("users/sshpublickey") - @EnforceAtLeastStudent - public ResponseEntity deleteSshPublicKey() { - User user = userRepository.getUser(); - log.debug("REST request to remove SSH key of user {}", user.getLogin()); - userRepository.updateUserSshPublicKeyHash(user.getId(), null, null); - - log.debug("Successfully deleted SSH key of user {}", user.getLogin()); - return ResponseEntity.ok().build(); - } - - /** - * GET users/vcsToken : get the vcsToken for of a user for a participation - * - * @param participationId the participation for which the access token should be fetched - * - * @return the versionControlAccessToken belonging to the provided participation and user - */ - @GetMapping("users/vcsToken") - @EnforceAtLeastStudent - public ResponseEntity getVcsAccessToken(@RequestParam("participationId") Long participationId) { - User user = userRepository.getUser(); - - log.debug("REST request to get VCS access token of user {} for participation {}", user.getLogin(), participationId); - return ResponseEntity.ok(userService.getParticipationVcsAccessTokenForUserAndParticipationIdOrElseThrow(user, participationId).getVcsAccessToken()); - } - - /** - * PUT users/vcsToken : get the vcsToken for of a user for a participation - * - * @param participationId the participation for which the access token should be fetched - * - * @return the versionControlAccessToken belonging to the provided participation and user - */ - @PutMapping("users/vcsToken") - @EnforceAtLeastStudent - public ResponseEntity createVcsAccessToken(@RequestParam("participationId") Long participationId) { - User user = userRepository.getUser(); - - log.debug("REST request to create a new VCS access token for user {} for participation {}", user.getLogin(), participationId); - return ResponseEntity.ok(userService.createParticipationVcsAccessTokenForUserAndParticipationIdOrElseThrow(user, participationId).getVcsAccessToken()); - } } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/competency/CourseCompetencyResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/competency/CourseCompetencyResource.java index a3fdf0875c31..036b673d1674 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/competency/CourseCompetencyResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/competency/CourseCompetencyResource.java @@ -46,9 +46,10 @@ import de.tum.in.www1.artemis.service.competency.CompetencyProgressService; import de.tum.in.www1.artemis.service.competency.CompetencyRelationService; import de.tum.in.www1.artemis.service.competency.CourseCompetencyService; +import de.tum.in.www1.artemis.service.connectors.pyris.dto.competency.PyrisCompetencyExtractionInputDTO; import de.tum.in.www1.artemis.service.feature.Feature; import de.tum.in.www1.artemis.service.feature.FeatureToggle; -import de.tum.in.www1.artemis.service.iris.session.IrisCompetencyGenerationSessionService; +import de.tum.in.www1.artemis.service.iris.IrisCompetencyGenerationService; import de.tum.in.www1.artemis.web.rest.dto.CourseCompetencyProgressDTO; import de.tum.in.www1.artemis.web.rest.dto.SearchResultPageDTO; import de.tum.in.www1.artemis.web.rest.dto.competency.CompetencyJolPairDTO; @@ -80,7 +81,7 @@ public class CourseCompetencyResource { private final CompetencyRelationService competencyRelationService; - private final Optional irisCompetencyGenerationSessionService; + private final Optional irisCompetencyGenerationService; private final CompetencyJolService competencyJolService; @@ -91,7 +92,7 @@ public class CourseCompetencyResource { public CourseCompetencyResource(UserRepository userRepository, CourseCompetencyService courseCompetencyService, CourseCompetencyRepository courseCompetencyRepository, CourseRepository courseRepository, CompetencyProgressService competencyProgressService, CompetencyProgressRepository competencyProgressRepository, CompetencyRelationRepository competencyRelationRepository, CompetencyRelationService competencyRelationService, - Optional irisCompetencyGenerationSessionService, CompetencyJolService competencyJolService, + Optional irisCompetencyGenerationService, CompetencyJolService competencyJolService, AuthorizationCheckService authorizationCheckService) { this.userRepository = userRepository; this.courseCompetencyService = courseCompetencyService; @@ -101,7 +102,7 @@ public CourseCompetencyResource(UserRepository userRepository, CourseCompetencyS this.competencyProgressRepository = competencyProgressRepository; this.competencyRelationRepository = competencyRelationRepository; this.competencyRelationService = competencyRelationService; - this.irisCompetencyGenerationSessionService = irisCompetencyGenerationSessionService; + this.irisCompetencyGenerationService = irisCompetencyGenerationService; this.competencyJolService = competencyJolService; this.authorizationCheckService = authorizationCheckService; } @@ -332,25 +333,25 @@ public ResponseEntity removeCompetencyRelation(@PathVariable long courseId } /** - * POST courses/:courseId/course-competencies/generate-from-description : Generates a list of course competencies from a given course description by using - * IRIS. + * POST courses/:courseId/course-competencies/:competencyId/competencies/generate-from-description + * Generates a list of competencies from a given course description with IRIS. * - * @param courseId the id of the current course - * @param courseDescription the text description of the course - * @return the ResponseEntity with status 200 (OK) and body the generated competencies + * @param courseId the id of the current course + * @param input the course description and current competencies + * @return the ResponseEntity with status 202 (Accepted) */ @PostMapping("courses/{courseId}/course-competencies/generate-from-description") @EnforceAtLeastEditorInCourse - public ResponseEntity> generateCompetenciesFromCourseDescription(@PathVariable Long courseId, @RequestBody String courseDescription) { - var irisService = irisCompetencyGenerationSessionService.orElseThrow(); + public ResponseEntity generateCompetenciesFromCourseDescription(@PathVariable Long courseId, @RequestBody PyrisCompetencyExtractionInputDTO input) { + var competencyGenerationService = irisCompetencyGenerationService.orElseThrow(); var user = userRepository.getUserWithGroupsAndAuthorities(); var course = courseRepository.findByIdElseThrow(courseId); - var session = irisService.getOrCreateSession(course, user); - irisService.addUserTextMessageToSession(session, courseDescription); - var competencies = irisService.executeRequest(session); + // Start the Iris competency generation pipeline for the given course. + // The generated competencies will be sent async over the websocket on the topic /topic/iris/competencies/{courseId} + competencyGenerationService.executeCompetencyExtractionPipeline(user, course, input.courseDescription(), input.currentCompetencies()); - return ResponseEntity.ok(competencies); + return ResponseEntity.accepted().build(); } /** diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/open/PublicAccountResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/open/PublicAccountResource.java index 9480b6e77d79..7950c09cb773 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/open/PublicAccountResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/open/PublicAccountResource.java @@ -165,6 +165,7 @@ public ResponseEntity getAccount() { UserDTO userDTO = new UserDTO(user); // we set this value on purpose here: the user can only fetch their own information, make the token available for constructing the token-based clone-URL userDTO.setVcsAccessToken(user.getVcsAccessToken()); + userDTO.setVcsAccessTokenExpiryDate(user.getVcsAccessTokenExpiryDate()); userDTO.setSshPublicKey(user.getSshPublicKey()); log.info("GET /account {} took {}ms", user.getLogin(), System.currentTimeMillis() - start); return ResponseEntity.ok(userDTO); diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/open/PublicPyrisStatusUpdateResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/open/PublicPyrisStatusUpdateResource.java index d2bf76e78491..f843fb729484 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/open/PublicPyrisStatusUpdateResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/open/PublicPyrisStatusUpdateResource.java @@ -16,7 +16,9 @@ import de.tum.in.www1.artemis.service.connectors.pyris.PyrisJobService; import de.tum.in.www1.artemis.service.connectors.pyris.PyrisStatusUpdateService; import de.tum.in.www1.artemis.service.connectors.pyris.dto.chat.PyrisChatStatusUpdateDTO; +import de.tum.in.www1.artemis.service.connectors.pyris.dto.competency.PyrisCompetencyStatusUpdateDTO; import de.tum.in.www1.artemis.service.connectors.pyris.dto.lectureingestionwebhook.PyrisLectureIngestionStatusUpdateDTO; +import de.tum.in.www1.artemis.service.connectors.pyris.job.CompetencyExtractionJob; import de.tum.in.www1.artemis.service.connectors.pyris.job.CourseChatJob; import de.tum.in.www1.artemis.service.connectors.pyris.job.ExerciseChatJob; import de.tum.in.www1.artemis.service.connectors.pyris.job.IngestionWebhookJob; @@ -27,7 +29,7 @@ /** * REST controller for providing Pyris access to Artemis internal data and status updates. * All endpoints in this controller use custom token based authentication. - * See {@link PyrisJobService#getAndAuthenticateJobFromHeaderElseThrow(HttpServletRequest)} for more information. + * See {@link PyrisJobService#getAndAuthenticateJobFromHeaderElseThrow(HttpServletRequest, Class)} for more information. */ @RestController @Profile("iris") @@ -58,15 +60,12 @@ public PublicPyrisStatusUpdateResource(PyrisJobService pyrisJobService, PyrisSta @PostMapping("pipelines/tutor-chat/runs/{runId}/status") // TODO: Rename this to 'exercise-chat' with next breaking Pyris version @EnforceNothing public ResponseEntity setStatusOfJob(@PathVariable String runId, @RequestBody PyrisChatStatusUpdateDTO statusUpdateDTO, HttpServletRequest request) { - var job = pyrisJobService.getAndAuthenticateJobFromHeaderElseThrow(request); + var job = pyrisJobService.getAndAuthenticateJobFromHeaderElseThrow(request, ExerciseChatJob.class); if (!Objects.equals(job.jobId(), runId)) { throw new ConflictException("Run ID in URL does not match run ID in request body", "Job", "runIdMismatch"); } - if (!(job instanceof ExerciseChatJob exerciseChatJob)) { - throw new ConflictException("Run ID is not a exercise chat job", "Job", "invalidRunId"); - } - pyrisStatusUpdateService.handleStatusUpdate(exerciseChatJob, statusUpdateDTO); + pyrisStatusUpdateService.handleStatusUpdate(job, statusUpdateDTO); return ResponseEntity.ok().build(); } @@ -86,15 +85,38 @@ public ResponseEntity setStatusOfJob(@PathVariable String runId, @RequestB @PostMapping("pipelines/course-chat/runs/{runId}/status") @EnforceNothing public ResponseEntity setStatusOfCourseChatJob(@PathVariable String runId, @RequestBody PyrisChatStatusUpdateDTO statusUpdateDTO, HttpServletRequest request) { - var job = pyrisJobService.getAndAuthenticateJobFromHeaderElseThrow(request); + var job = pyrisJobService.getAndAuthenticateJobFromHeaderElseThrow(request, CourseChatJob.class); if (!Objects.equals(job.jobId(), runId)) { throw new ConflictException("Run ID in URL does not match run ID in request body", "Job", "runIdMismatch"); } - if (!(job instanceof CourseChatJob courseChatJob)) { - throw new ConflictException("Run ID is not a course chat job", "Job", "invalidRunId"); + + pyrisStatusUpdateService.handleStatusUpdate(job, statusUpdateDTO); + + return ResponseEntity.ok().build(); + } + + /** + * POST public/pyris/pipelines/competency-extraction/runs/:runId/status : Send the competencies extracted from a course description in a status update + *

+ * Uses custom token based authentication. + * + * @param runId the ID of the job + * @param statusUpdateDTO the status update + * @param request the HTTP request + * @throws ConflictException if the run ID in the URL does not match the run ID in the request body + * @throws AccessForbiddenException if the token is invalid + * @return a {@link ResponseEntity} with status {@code 200 (OK)} + */ + @PostMapping("pipelines/competency-extraction/runs/{runId}/status") + @EnforceNothing + public ResponseEntity setCompetencyExtractionJobStatus(@PathVariable String runId, @RequestBody PyrisCompetencyStatusUpdateDTO statusUpdateDTO, + HttpServletRequest request) { + var job = pyrisJobService.getAndAuthenticateJobFromHeaderElseThrow(request, CompetencyExtractionJob.class); + if (!Objects.equals(job.jobId(), runId)) { + throw new ConflictException("Run ID in URL does not match run ID in request body", "Job", "runIdMismatch"); } - pyrisStatusUpdateService.handleStatusUpdate(courseChatJob, statusUpdateDTO); + pyrisStatusUpdateService.handleStatusUpdate(job, statusUpdateDTO); return ResponseEntity.ok().build(); } @@ -112,7 +134,7 @@ public ResponseEntity setStatusOfCourseChatJob(@PathVariable String runId, @PostMapping("webhooks/ingestion/runs/{runId}/status") @EnforceNothing public ResponseEntity setStatusOfIngestionJob(@PathVariable String runId, @RequestBody PyrisLectureIngestionStatusUpdateDTO statusUpdateDTO, HttpServletRequest request) { - PyrisJob job = pyrisJobService.getAndAuthenticateJobFromHeaderElseThrow(request); + PyrisJob job = pyrisJobService.getAndAuthenticateJobFromHeaderElseThrow(request, PyrisJob.class); if (!job.jobId().equals(runId)) { throw new ConflictException("Run ID in URL does not match run ID in request body", "Job", "runIdMismatch"); } diff --git a/src/main/resources/config/application.yml b/src/main/resources/config/application.yml index b216a43a2bf6..8c66b009451c 100644 --- a/src/main/resources/config/application.yml +++ b/src/main/resources/config/application.yml @@ -87,6 +87,8 @@ artemis: default: "ls1tum/artemis-swift-swiftlint-docker:latest" ocaml: default: "ls1tum/artemis-ocaml-docker:v1" + rust: + default: "ghcr.io/ls1intum/artemis-rust-docker:v0.9.70" management: endpoints: @@ -196,6 +198,9 @@ spring: thread-name-prefix: artemis-scheduling- pool: size: 2 + threads: + virtual: + enabled: true thymeleaf: mode: HTML output: diff --git a/src/main/resources/templates/aeolus/rust/default.sh b/src/main/resources/templates/aeolus/rust/default.sh new file mode 100644 index 000000000000..d1ba23293a58 --- /dev/null +++ b/src/main/resources/templates/aeolus/rust/default.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -e +export AEOLUS_INITIAL_DIRECTORY=${PWD} +build () { + echo '⚙️ executing build' + cargo build --verbose +} + +run_all_tests () { + echo '⚙️ executing run_all_tests' + cargo nextest run --profile ci +} + +main () { + if [[ "${1}" == "aeolus_sourcing" ]]; then + return 0 # just source to use the methods in the subshell, no execution + fi + local _script_name + _script_name=${BASH_SOURCE[0]:-$0} + cd "${AEOLUS_INITIAL_DIRECTORY}" + bash -c "source ${_script_name} aeolus_sourcing; build" + cd "${AEOLUS_INITIAL_DIRECTORY}" + bash -c "source ${_script_name} aeolus_sourcing; run_all_tests" +} + +main "${@}" diff --git a/src/main/resources/templates/aeolus/rust/default.yaml b/src/main/resources/templates/aeolus/rust/default.yaml new file mode 100644 index 000000000000..4cd44c110048 --- /dev/null +++ b/src/main/resources/templates/aeolus/rust/default.yaml @@ -0,0 +1,14 @@ +api: v0.0.1 +metadata: + name: Rust + id: rust + description: Test crate using cargo +actions: + - name: build + script: cargo build --verbose + - name: run_all_tests + script: cargo nextest run --profile ci + results: + - name: junit_target/nextest/ci/junit.xml + path: target/nextest/ci/junit.xml + type: junit diff --git a/src/main/resources/templates/gitlabci/rust/regularRuns/.gitlab-ci.yml b/src/main/resources/templates/gitlabci/rust/regularRuns/.gitlab-ci.yml new file mode 100644 index 000000000000..39e416158a9c --- /dev/null +++ b/src/main/resources/templates/gitlabci/rust/regularRuns/.gitlab-ci.yml @@ -0,0 +1,47 @@ +stages: + - test + - upload + + +test-job: + image: ${ARTEMIS_BUILD_DOCKER_IMAGE} + stage: test + only: + variables: + - $CI_COMMIT_BRANCH == $ARTEMIS_SUBMISSION_GIT_BRANCH + allow_failure: true + variables: + GIT_STRATEGY: none + script: + - git clone --branch ${ARTEMIS_TEST_GIT_BRANCH} ${CI_SERVER_PROTOCOL}://${ARTEMIS_TEST_GIT_USER}:${ARTEMIS_TEST_GIT_TOKEN}@${CI_SERVER_HOST}:${CI_SERVER_PORT}/${CI_PROJECT_NAMESPACE}/${ARTEMIS_TEST_GIT_REPOSITORY_SLUG} . + - git clone --branch ${ARTEMIS_SUBMISSION_GIT_BRANCH} ${CI_SERVER_PROTOCOL}://${ARTEMIS_TEST_GIT_USER}:${ARTEMIS_TEST_GIT_TOKEN}@${CI_SERVER_HOST}:${CI_SERVER_PORT}/${CI_PROJECT_NAMESPACE}/${CI_PROJECT_NAME} assignment + - export ARTEMIS_NOTIFICATION_SECRET=[hidden] # Workaround for overwriting the secret + - export ARTEMIS_TEST_GIT_TOKEN=[hidden] + - cargo nextest run --profile ci | tee -a "${ARTEMIS_BUILD_LOGS_FILE}" && echo "ARTEMIS_BUILD_STATUS=success" > .env || echo "ARTEMIS_BUILD_STATUS=failed" > .env + - test -e target/nextest/ci/junit.xml && sed -i 's/]*>//g ; s/<\/testsuites>/<\/testsuite>/g' target/nextest/ci/junit.xml # not supported by notification plugin + after_script: + - echo "ARTEMIS_TEST_GIT_HASH=$(git rev-parse HEAD)" >> .env + - echo "ARTEMIS_SUBMISSION_GIT_HASH=${CI_COMMIT_SHA}" >> .env + - echo "ARTEMIS_SUBMISSION_GIT_REPOSITORY_SLUG=${CI_PROJECT_NAME}" >> .env + artifacts: + paths: + - ${ARTEMIS_BUILD_LOGS_FILE} + - target/nextest/ci/*.xml + reports: + dotenv: .env + + +upload-job: + image: ${ARTEMIS_NOTIFICATION_PLUGIN_DOCKER_IMAGE} + stage: upload + dependencies: + - test-job + only: + variables: + - $CI_COMMIT_BRANCH == $ARTEMIS_SUBMISSION_GIT_BRANCH + variables: + GIT_STRATEGY: none + script: + - cp -r /notification-plugin/* . + - export ARTEMIS_TEST_RESULTS_DIR="target/nextest/ci" # override project variable + - gradle run diff --git a/src/main/resources/templates/jenkins/rust/regularRuns/pipeline.groovy b/src/main/resources/templates/jenkins/rust/regularRuns/pipeline.groovy new file mode 100644 index 000000000000..965d7423ce7c --- /dev/null +++ b/src/main/resources/templates/jenkins/rust/regularRuns/pipeline.groovy @@ -0,0 +1,56 @@ +/* + * This file configures the actual build steps for the automatic grading. + * + * !!! + * For regular exercises, there is no need to make changes to this file. + * Only this base configuration is actively supported by the Artemis maintainers + * and/or your Artemis instance administrators. + * !!! + */ + +dockerImage = '#dockerImage' +dockerFlags = '#dockerArgs' + +/** + * Main function called by Jenkins. + */ +void testRunner() { + docker.image(dockerImage).inside(dockerFlags) { c -> + runTestSteps() + } +} + +private void runTestSteps() { + test() +} + +/** + * Run unit tests + */ +private void test() { + stage('Test') { + sh 'cargo nextest run --profile ci' + } +} + +/** + * Script of the post build tasks aggregating all JUnit files in $WORKSPACE/results. + * + * Called by Jenkins. + */ +void postBuildTasks() { + sh ''' + rm -rf results + mkdir results + if [ -e target/nextest/ci/junit.xml ] + then + sed -i 's/]*>//g ; s/<\\/testsuites>/<\\/testsuite>/g' target/nextest/ci/junit.xml + fi + cp target/nextest/ci/junit.xml $WORKSPACE/results/ || true + sed -i 's/[^[:print:]\t]/�/g' $WORKSPACE/results/*.xml || true + ''' +} + +// very important, do not remove +// required so that Jenkins finds the methods defined in this script +return this diff --git a/src/main/resources/templates/rust/exercise/.gitattributes b/src/main/resources/templates/rust/exercise/.gitattributes new file mode 100644 index 000000000000..2677732ca2e9 --- /dev/null +++ b/src/main/resources/templates/rust/exercise/.gitattributes @@ -0,0 +1,5 @@ +* text=auto eol=lf + +*.rs text eol=lf diff=rust +Cargo.toml text eol=lf +Cargo.lock text eol=lf diff --git a/src/main/resources/templates/rust/exercise/.gitignore b/src/main/resources/templates/rust/exercise/.gitignore new file mode 100644 index 000000000000..ea8c4bf7f35f --- /dev/null +++ b/src/main/resources/templates/rust/exercise/.gitignore @@ -0,0 +1 @@ +/target diff --git a/src/main/resources/templates/rust/exercise/Cargo.lock b/src/main/resources/templates/rust/exercise/Cargo.lock new file mode 100644 index 000000000000..2f5f96ba7454 --- /dev/null +++ b/src/main/resources/templates/rust/exercise/Cargo.lock @@ -0,0 +1,100 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "autocfg" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +dependencies = [ + "num-traits", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "libc" +version = "0.2.155" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rust-template-exercise" +version = "0.1.0" +dependencies = [ + "chrono", + "rand", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" diff --git a/src/main/resources/templates/rust/exercise/Cargo.toml b/src/main/resources/templates/rust/exercise/Cargo.toml new file mode 100644 index 000000000000..34b160c5368d --- /dev/null +++ b/src/main/resources/templates/rust/exercise/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "rust-template-exercise" +version = "0.1.0" +edition = "2021" + +[dependencies] +chrono = { version = "0.4.38", default-features = false } +rand = "0.8.5" diff --git a/src/main/resources/templates/rust/exercise/src/bubble_sort.rs b/src/main/resources/templates/rust/exercise/src/bubble_sort.rs new file mode 100644 index 000000000000..0587d2852dcf --- /dev/null +++ b/src/main/resources/templates/rust/exercise/src/bubble_sort.rs @@ -0,0 +1,14 @@ +use chrono::NaiveDate; + +pub struct BubbleSort; + +impl BubbleSort { + /// Sorts items with the Bubble Sort algorithm. + /// + /// Arguments: + /// + /// * `input`: slice of items to be sorted + pub fn perform_sort(&self, input: &mut [NaiveDate]) { + todo!("implement"); + } +} diff --git a/src/main/resources/templates/rust/exercise/src/context.rs b/src/main/resources/templates/rust/exercise/src/context.rs new file mode 100644 index 000000000000..30940f4db7a7 --- /dev/null +++ b/src/main/resources/templates/rust/exercise/src/context.rs @@ -0,0 +1 @@ +// TODO: Create and implement a Context struct according to the UML class diagram diff --git a/src/main/resources/templates/rust/exercise/src/lib.rs b/src/main/resources/templates/rust/exercise/src/lib.rs new file mode 100644 index 000000000000..930d9899389d --- /dev/null +++ b/src/main/resources/templates/rust/exercise/src/lib.rs @@ -0,0 +1,5 @@ +pub mod bubble_sort; +pub mod context; +pub mod merge_sort; +pub mod policy; +pub mod sort_strategy; diff --git a/src/main/resources/templates/rust/exercise/src/main.rs b/src/main/resources/templates/rust/exercise/src/main.rs new file mode 100644 index 000000000000..9263a66a267e --- /dev/null +++ b/src/main/resources/templates/rust/exercise/src/main.rs @@ -0,0 +1,53 @@ +use std::ops::RangeInclusive; +use std::time::Duration; + +use chrono::{NaiveDate, TimeDelta}; +use rand::{thread_rng, Rng}; + +const ITERATIONS: usize = 10; +const LENGTH_MIN: usize = 5; +const LENGTH_MAX: usize = 15; + +/// Main function. +/// Add code to demonstrate your implementation here. +fn main() { + todo!("Init Context and Policy"); + + // Run multiple times to simulate different sorting strategies + for _ in 0..ITERATIONS { + let mut dates = create_random_dates(); + todo!("Configure context"); + println!("Unsorted Array of course dates = {dates:#?}"); + + todo!("Sort dates"); + + println!("Sorted Array of course dates = {dates:#?}"); + } +} + +/// Generates a [Vec] of random [NaiveDate] objects with a random length +/// between [LENGTH_MIN] and [LENGTH_MAX]. +fn create_random_dates() -> Vec { + let length = thread_rng().gen_range(LENGTH_MIN..=LENGTH_MAX); + + let date_format = "%Y-%m-%d"; + let low_date = NaiveDate::parse_from_str("2024-09-15", date_format).unwrap(); + let high_date = NaiveDate::parse_from_str("2025-01-15", date_format).unwrap(); + + let mut dates = Vec::new(); + dates.resize_with(length, || random_date_within(low_date..=high_date)); + dates +} + +/// Creates a random Date within the given range. +fn random_date_within(range: RangeInclusive) -> NaiveDate { + let (start, end) = range.into_inner(); + + let max_delta = end - start; + let max_duration = max_delta.to_std().unwrap(); + + let random_duration = thread_rng().gen_range(Duration::ZERO..=max_duration); + let random_delta = TimeDelta::from_std(random_duration).unwrap(); + + start + random_delta +} diff --git a/src/main/resources/templates/rust/exercise/src/merge_sort.rs b/src/main/resources/templates/rust/exercise/src/merge_sort.rs new file mode 100644 index 000000000000..78fbb6b6dd33 --- /dev/null +++ b/src/main/resources/templates/rust/exercise/src/merge_sort.rs @@ -0,0 +1,14 @@ +use chrono::NaiveDate; + +pub struct MergeSort; + +impl MergeSort { + /// Sorts items with the Merge Sort algorithm. + /// + /// Arguments: + /// + /// * `input`: slice of items to be sorted + pub fn perform_sort(&self, input: &mut [NaiveDate]) { + todo!("implement"); + } +} diff --git a/src/main/resources/templates/rust/exercise/src/policy.rs b/src/main/resources/templates/rust/exercise/src/policy.rs new file mode 100644 index 000000000000..ebaa1dfd92d3 --- /dev/null +++ b/src/main/resources/templates/rust/exercise/src/policy.rs @@ -0,0 +1 @@ +// TODO: Create and implement a Policy class as described in the problem statement diff --git a/src/main/resources/templates/rust/exercise/src/sort_strategy.rs b/src/main/resources/templates/rust/exercise/src/sort_strategy.rs new file mode 100644 index 000000000000..ccd9a65d5246 --- /dev/null +++ b/src/main/resources/templates/rust/exercise/src/sort_strategy.rs @@ -0,0 +1,2 @@ +// TODO: Create a SortStrategy interface according to the UML class diagram +// TODO: Make the sorting algorithms implement this trait. diff --git a/src/main/resources/templates/rust/readme b/src/main/resources/templates/rust/readme new file mode 100755 index 000000000000..9510a4ca0428 --- /dev/null +++ b/src/main/resources/templates/rust/readme @@ -0,0 +1,91 @@ +# Sorting with the Strategy Pattern + +In this exercise, we want to implement sorting algorithms and choose them based on runtime specific variables. + +### Part 1: Sorting + +First, we need to implement two sorting algorithms, in this case `MergeSort` and `BubbleSort`. + +**You have the following tasks:** + +1. [task][Implement Bubble Sort](test_bubble_sort) +Implement the method `perform_sort(&mut [NaiveDate])` in the struct `BubbleSort`. Make sure to follow the Bubble Sort algorithm exactly. + +2. [task][Implement Merge Sort](test_merge_sort) +Implement the method `perform_sort(&mut [NaiveDate])` in the struct `MergeSort`. Make sure to follow the Merge Sort algorithm exactly. + +### Part 2: Strategy Pattern + +We want the application to apply different algorithms for sorting a slice of `NaiveDate` objects. +Use the strategy pattern to select the right sorting algorithm at runtime. + +**You have the following tasks:** + +1. [task][SortStrategy Interface](test_sort_strategy_trait,test_sort_strategy_methods,test_sort_strategy_supertrait,test_merge_sort_struct,test_bubble_sort_struct) +Create a `SortStrategy` trait and adjust the sorting algorithms so that they implement this interface. +Also make sure to declare `std::any::Any` as a supertrait. + +2. [task][Context Class](test_context_fields,test_context_methods) +Create and implement a `Context` struct following the below class diagram. +Also add a getter for `sort_algorithm` with the signature `sort_algorithm(&self) -> &dyn SortStrategy`. + +3. [task][Context Policy](test_policy_fields,test_policy_methods) +Create and implement a `Policy` struct following the below class diagram with a simple configuration mechanism: + + 1. [task][Select MergeSort](test_merge_sort_struct,test_use_merge_sort_for_big_list) + Select `MergeSort` when the List has more than 10 dates. + + 2. [task][Select BubbleSort](test_bubble_sort_struct,test_use_bubble_sort_for_small_list) + Select `BubbleSort` when the List has less or equal 10 dates. + +4. Complete the `main()` function which demonstrates switching between two strategies at runtime. + +@startuml + +class Policy { + +new(&RefCell) + +configure(&[NaiveDate]) +} + +class Context { + +new() + +sort(&mut [NaiveDate]) +} + +interface Any { +} + +interface SortStrategy { + +perform_sort(&mut [NaiveDate]) +} + +class BubbleSort { + +perform_sort(&mut [NaiveDate]) +} + +class MergeSort { + +perform_sort(&mut [NaiveDate]) +} + +MergeSort -up-|> SortStrategy #testsColor(test_merge_sort_struct) +BubbleSort -up-|> SortStrategy #testsColor(test_bubble_sort_struct) +SortStrategy -up-|> Any #testsColor(test_sort_strategy_supertrait) +Policy -right-> Context #testsColor(test_policy_fields): context +Context -right-> SortStrategy #testsColor(test_context_fields): sort_algorithm + +hide empty fields +hide empty methods + +@enduml + + +### Part 3: Optional Challenges + +(These are not tested) + +1. Create a new struct `QuickSort` that implements `SortStrategy` and implement the Quick Sort algorithm. + +2. Make the method `perform_sort(&mut [NaiveDate])` generic, so that other types can also be sorted by the same method. +**Hint:** Have a look at Rust Generics and the trait `Ord`. + +3. Think about a useful decision in `Policy` when to use the new `QuickSort` algorithm. diff --git a/src/main/resources/templates/rust/solution/.gitattributes b/src/main/resources/templates/rust/solution/.gitattributes new file mode 100644 index 000000000000..2677732ca2e9 --- /dev/null +++ b/src/main/resources/templates/rust/solution/.gitattributes @@ -0,0 +1,5 @@ +* text=auto eol=lf + +*.rs text eol=lf diff=rust +Cargo.toml text eol=lf +Cargo.lock text eol=lf diff --git a/src/main/resources/templates/rust/solution/.gitignore b/src/main/resources/templates/rust/solution/.gitignore new file mode 100644 index 000000000000..ea8c4bf7f35f --- /dev/null +++ b/src/main/resources/templates/rust/solution/.gitignore @@ -0,0 +1 @@ +/target diff --git a/src/main/resources/templates/rust/solution/Cargo.lock b/src/main/resources/templates/rust/solution/Cargo.lock new file mode 100644 index 000000000000..2f5f96ba7454 --- /dev/null +++ b/src/main/resources/templates/rust/solution/Cargo.lock @@ -0,0 +1,100 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "autocfg" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +dependencies = [ + "num-traits", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "libc" +version = "0.2.155" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rust-template-exercise" +version = "0.1.0" +dependencies = [ + "chrono", + "rand", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" diff --git a/src/main/resources/templates/rust/solution/Cargo.toml b/src/main/resources/templates/rust/solution/Cargo.toml new file mode 100644 index 000000000000..34b160c5368d --- /dev/null +++ b/src/main/resources/templates/rust/solution/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "rust-template-exercise" +version = "0.1.0" +edition = "2021" + +[dependencies] +chrono = { version = "0.4.38", default-features = false } +rand = "0.8.5" diff --git a/src/main/resources/templates/rust/solution/src/bubble_sort.rs b/src/main/resources/templates/rust/solution/src/bubble_sort.rs new file mode 100644 index 000000000000..82c751842786 --- /dev/null +++ b/src/main/resources/templates/rust/solution/src/bubble_sort.rs @@ -0,0 +1,23 @@ +use std::cmp::Ord; + +use crate::sort_strategy::SortStrategy; + +pub struct BubbleSort; + +impl SortStrategy for BubbleSort { + /// Sorts items with the Bubble Sort algorithm. + /// + /// Arguments: + /// + /// * `input`: slice of items to be sorted + fn perform_sort(&self, input: &mut [T]) { + let len = input.len(); + for i in (0..len).rev() { + for j in 0..i { + if input[j] > input[j + 1] { + input.swap(j, j + 1); + } + } + } + } +} diff --git a/src/main/resources/templates/rust/solution/src/context.rs b/src/main/resources/templates/rust/solution/src/context.rs new file mode 100644 index 000000000000..71cd0d711753 --- /dev/null +++ b/src/main/resources/templates/rust/solution/src/context.rs @@ -0,0 +1,36 @@ +use crate::sort_strategy::SortStrategy; +use chrono::NaiveDate; +use std::ops::Deref; + +pub struct Context { + sort_algorithm: Option>>, +} + +impl Context { + pub fn new() -> Context { + Context { + sort_algorithm: None, + } + } + + /// Runs the configured sorting algorithm. + pub fn sort(&self, data: &mut [NaiveDate]) { + let sort_algorithm = self + .sort_algorithm + .as_ref() + .expect("sort_algorithm has to be set before sort() is called"); + + sort_algorithm.perform_sort(data); + } + + pub fn set_sort_algorithm(&mut self, sort_algorithm: Box>) { + self.sort_algorithm = Some(sort_algorithm); + } + + pub fn sort_algorithm(&self) -> &dyn SortStrategy { + self.sort_algorithm + .as_ref() + .expect("sort_algorithm has to be set") + .deref() + } +} diff --git a/src/main/resources/templates/rust/solution/src/lib.rs b/src/main/resources/templates/rust/solution/src/lib.rs new file mode 100644 index 000000000000..930d9899389d --- /dev/null +++ b/src/main/resources/templates/rust/solution/src/lib.rs @@ -0,0 +1,5 @@ +pub mod bubble_sort; +pub mod context; +pub mod merge_sort; +pub mod policy; +pub mod sort_strategy; diff --git a/src/main/resources/templates/rust/solution/src/main.rs b/src/main/resources/templates/rust/solution/src/main.rs new file mode 100644 index 000000000000..0d8d362fafc9 --- /dev/null +++ b/src/main/resources/templates/rust/solution/src/main.rs @@ -0,0 +1,59 @@ +use std::time::Duration; +use std::{cell::RefCell, ops::RangeInclusive}; + +use chrono::{NaiveDate, TimeDelta}; +use rand::{thread_rng, Rng}; + +use rust_template_exercise::{context::Context, policy::Policy}; + +const ITERATIONS: usize = 10; +const LENGTH_MIN: usize = 5; +const LENGTH_MAX: usize = 15; + +/// Main function. +/// Add code to demonstrate your implementation here. +fn main() { + // Init Context and Policy + let context = RefCell::new(Context::new()); + let mut policy = Policy::new(&context); + + // Run multiple times to simulate different sorting strategies + for _ in 0..ITERATIONS { + let mut dates = create_random_dates(); + // Configure context + policy.configure(&dates); + println!("Unsorted Array of course dates = {dates:#?}"); + + // Sort dates + context.borrow().sort(&mut dates); + + println!("Sorted Array of course dates = {dates:#?}"); + } +} + +/// Generates a [Vec] of random [NaiveDate] objects with a random length +/// between [LENGTH_MIN] and [LENGTH_MAX]. +fn create_random_dates() -> Vec { + let length = thread_rng().gen_range(LENGTH_MIN..=LENGTH_MAX); + + let date_format = "%Y-%m-%d"; + let low_date = NaiveDate::parse_from_str("2024-09-15", date_format).unwrap(); + let high_date = NaiveDate::parse_from_str("2025-01-15", date_format).unwrap(); + + let mut dates = Vec::new(); + dates.resize_with(length, || random_date_within(low_date..=high_date)); + dates +} + +/// Creates a random Date within the given range. +fn random_date_within(range: RangeInclusive) -> NaiveDate { + let (start, end) = range.into_inner(); + + let max_delta = end - start; + let max_duration = max_delta.to_std().unwrap(); + + let random_duration = thread_rng().gen_range(Duration::ZERO..=max_duration); + let random_delta = TimeDelta::from_std(random_duration).unwrap(); + + start + random_delta +} diff --git a/src/main/resources/templates/rust/solution/src/merge_sort.rs b/src/main/resources/templates/rust/solution/src/merge_sort.rs new file mode 100644 index 000000000000..a1887c6d38f8 --- /dev/null +++ b/src/main/resources/templates/rust/solution/src/merge_sort.rs @@ -0,0 +1,59 @@ +use std::cmp::Ord; + +use crate::sort_strategy::SortStrategy; + +pub struct MergeSort; + +impl SortStrategy for MergeSort { + /// Sorts items with the Merge Sort algorithm. + /// + /// Arguments: + /// + /// * `input`: slice of items to be sorted + fn perform_sort(&self, input: &mut [T]) { + mergesort(input); + } +} + +fn mergesort(input: &mut [T]) { + if input.len() < 2 { + return; + } + + let middle = input.len() / 2; + let (left, right) = input.split_at_mut(middle); + mergesort(left); + mergesort(right); + merge(input, middle); +} + +fn merge(input: &mut [T], middle: usize) { + let mut result = Vec::with_capacity(input.len()); + + let mut left_index = 0; + let mut right_index = middle; + + while left_index < middle && right_index < input.len() { + if input[left_index] <= input[right_index] { + result.push(input[left_index]); + left_index += 1; + } else { + result.push(input[right_index]); + right_index += 1; + } + } + + if left_index < middle { + while left_index < middle { + result.push(input[left_index]); + left_index += 1; + } + } else { + while right_index < input.len() { + result.push(input[right_index]); + right_index += 1; + } + } + + input.copy_from_slice(&result); +} diff --git a/src/main/resources/templates/rust/solution/src/policy.rs b/src/main/resources/templates/rust/solution/src/policy.rs new file mode 100644 index 000000000000..a8e7e5203162 --- /dev/null +++ b/src/main/resources/templates/rust/solution/src/policy.rs @@ -0,0 +1,28 @@ +use crate::{ + bubble_sort::BubbleSort, context::Context, merge_sort::MergeSort, sort_strategy::SortStrategy, +}; +use chrono::NaiveDate; +use std::cell::RefCell; + +const SIZE_THRESHOLD: usize = 10; + +pub struct Policy<'a> { + context: &'a RefCell, +} + +impl<'a> Policy<'a> { + pub fn new(context: &'a RefCell) -> Policy<'a> { + Policy { context } + } + + /// Chooses a strategy depending on the number of items. + pub fn configure(&mut self, data: &[NaiveDate]) { + let sort_algorithm: Box> = if data.len() > SIZE_THRESHOLD { + Box::new(MergeSort) + } else { + Box::new(BubbleSort) + }; + + self.context.borrow_mut().set_sort_algorithm(sort_algorithm); + } +} diff --git a/src/main/resources/templates/rust/solution/src/sort_strategy.rs b/src/main/resources/templates/rust/solution/src/sort_strategy.rs new file mode 100644 index 000000000000..b924206e39ba --- /dev/null +++ b/src/main/resources/templates/rust/solution/src/sort_strategy.rs @@ -0,0 +1,10 @@ +use std::any::Any; + +pub trait SortStrategy: Any { + /// Sorts a slice of `T`s. + /// + /// Arguments: + /// + /// * `input`: slice of items to be sorted + fn perform_sort(&self, input: &mut [T]); +} diff --git a/src/main/resources/templates/rust/test/.config/nextest.toml b/src/main/resources/templates/rust/test/.config/nextest.toml new file mode 100644 index 000000000000..6a109e171680 --- /dev/null +++ b/src/main/resources/templates/rust/test/.config/nextest.toml @@ -0,0 +1,5 @@ +[profile.ci] +fail-fast = false + +[profile.ci.junit] +path = "junit.xml" diff --git a/src/main/resources/templates/rust/test/.gitattributes b/src/main/resources/templates/rust/test/.gitattributes new file mode 100644 index 000000000000..2677732ca2e9 --- /dev/null +++ b/src/main/resources/templates/rust/test/.gitattributes @@ -0,0 +1,5 @@ +* text=auto eol=lf + +*.rs text eol=lf diff=rust +Cargo.toml text eol=lf +Cargo.lock text eol=lf diff --git a/src/main/resources/templates/rust/test/.gitignore b/src/main/resources/templates/rust/test/.gitignore new file mode 100644 index 000000000000..a37c5236a444 --- /dev/null +++ b/src/main/resources/templates/rust/test/.gitignore @@ -0,0 +1,2 @@ +/target +/assignment diff --git a/src/main/resources/templates/rust/test/Cargo.lock b/src/main/resources/templates/rust/test/Cargo.lock new file mode 100644 index 000000000000..bb5595575a1a --- /dev/null +++ b/src/main/resources/templates/rust/test/Cargo.lock @@ -0,0 +1,183 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "autocfg" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +dependencies = [ + "num-traits", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "libc" +version = "0.2.155" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dee4364d9f3b902ef14fab8a1ddffb783a1cb6b4bba3bfc1fa3922732c7de97f" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rust-template-exercise" +version = "0.1.0" +dependencies = [ + "chrono", + "rand", +] + +[[package]] +name = "rust-template-test" +version = "0.1.0" +dependencies = [ + "chrono", + "rust-template-exercise", + "rust_template_test_macros", + "syn", +] + +[[package]] +name = "rust_template_test_macros" +version = "0.1.0" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc4b9b9bf2add8093d3f2c0204471e951b2285580335de42f9d2534f3ae7a8af" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "zerocopy" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854e949ac82d619ee9a14c66a1b674ac730422372ccb759ce0c39cabcf2bf8e6" +dependencies = [ + "byteorder", + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "125139de3f6b9d625c39e2efdd73d41bdac468ccd556556440e322be0e1bbd91" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/src/main/resources/templates/rust/test/Cargo.toml b/src/main/resources/templates/rust/test/Cargo.toml new file mode 100644 index 000000000000..07f82b3f09f0 --- /dev/null +++ b/src/main/resources/templates/rust/test/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "rust-template-test" +version = "0.1.0" +edition = "2021" + +[dependencies] +chrono = { version = "0.4.38", default-features = false } +rust-template-exercise = { path = "assignment" } +syn = { version = "2.0.72", features = ["full"] } +rust_template_test_macros = { path = "./rust_template_test_macros" } + +[build-dependencies] +syn = { version = "2.0.72", features = ["full"] } diff --git a/src/main/resources/templates/rust/test/build.rs b/src/main/resources/templates/rust/test/build.rs new file mode 100644 index 000000000000..850fcb846cce --- /dev/null +++ b/src/main/resources/templates/rust/test/build.rs @@ -0,0 +1,204 @@ +use std::error::Error; +use std::ffi::OsStr; +use std::fs::read_to_string; +use std::path::{Component, Path}; +use std::{fs, io}; + +use syn::{parse_file, FnArg, ImplItem, Item, TraitItem, Type, TypeParamBound}; + +const SRC_DIR: &str = "assignment/src"; + +fn main() { + println!("cargo::rerun-if-changed={SRC_DIR}"); + if let Err(err) = visit_dirs(Path::new(SRC_DIR), &process_file) { + eprintln!("Failed to analyze submission: {err}"); + } +} + +fn visit_dirs Result<(), Box>>( + dir: &Path, + cb: &F, +) -> Result<(), Box> { + for entry in fs::read_dir(dir).map_err(|e| { + io::Error::new( + e.kind(), + format!("Failed to read directory {}: {}", dir.display(), e), + ) + })? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + visit_dirs(&path, cb)?; + } else { + cb(&entry.path())?; + } + } + Ok(()) +} + +fn process_file(path: &Path) -> Result<(), Box> { + if path.extension() != Some(OsStr::new("rs")) { + return Ok(()); + } + + let file_content = read_to_string(path)?; + + let ast = parse_file(&file_content)?; + + let module = path + .strip_prefix(SRC_DIR)? + .with_extension("") + .components() + .map(Component::as_os_str) + .collect::>() + .join(OsStr::new("_")); + let module = module.to_str().ok_or("invalid UTF-8 in path")?; + + for item in ast.items { + match item { + Item::Enum(e) => { + println!("cargo::rustc-cfg=structure_{module}_enum_{}", e.ident); + + for v in e.variants { + println!( + "cargo::rustc-cfg=structure_{module}_enum_{}_variant_{}", + e.ident, v.ident + ); + } + } + Item::Fn(f) => println!("cargo::rustc-cfg=structure_{module}_fn_{}", f.sig.ident), + Item::Impl(impl_) => { + let self_path = if let Type::Path(p) = *impl_.self_ty { + p + } else { + continue; + }; + + let self_ident = if let Some(i) = self_path.path.segments.last().map(|s| &s.ident) { + i + } else { + continue; + }; + + if impl_.trait_.is_some() { + let trait_ident = if let Some(i) = impl_ + .trait_ + .as_ref() + .unwrap() + .1 + .segments + .last() + .map(|s| &s.ident) + { + i + } else { + continue; + }; + println!( + "cargo::rustc-cfg=structure_{module}_impl_{trait_ident}_for_{self_ident}" + ); + continue; + } + + for item in impl_.items { + match item { + ImplItem::Const(c) => println!( + "cargo::rustc-cfg=structure_{module}_impl_{self_ident}_const_{}", + c.ident + ), + ImplItem::Fn(f) => { + println!( + "cargo::rustc-cfg=structure_{module}_impl_{self_ident}_fn_{}", + f.sig.ident + ); + + if matches!(f.sig.inputs.first(), Some(&FnArg::Receiver(_))) { + println!( + "cargo::rustc-cfg=structure_{module}_impl_{self_ident}_method_{}", + f.sig.ident + ); + } + } + ImplItem::Type(t) => println!( + "cargo::rustc-cfg=structure_{module}_impl_{self_ident}_type_{}", + t.ident + ), + _ => continue, + } + } + } + Item::Struct(struct_) => { + println!( + "cargo::rustc-cfg=structure_{module}_struct_{}", + struct_.ident + ); + + for field in struct_.fields { + if let Some(field_ident) = field.ident { + println!( + "cargo::rustc-cfg=structure_{module}_struct_{}_field_{field_ident}", + struct_.ident + ); + } + } + } + Item::Trait(trait_) => { + println!("cargo::rustc-cfg=structure_{module}_trait_{}", trait_.ident); + + for supertrait in trait_.supertraits { + let supertrait = match supertrait { + TypeParamBound::Trait(supertrait) => supertrait, + _ => continue, + }; + let supertrait = &supertrait.path.segments.last().unwrap().ident; + println!( + "cargo::rustc-cfg=structure_{module}_trait_{}_supertrait_{supertrait}", + trait_.ident + ); + } + + for item in trait_.items { + match item { + TraitItem::Const(c) => println!( + "cargo::rustc-cfg=structure_{module}_trait_{}_const_{}", + trait_.ident, c.ident + ), + TraitItem::Fn(f) => { + println!( + "cargo::rustc-cfg=structure_{module}_trait_{}_fn_{}", + trait_.ident, f.sig.ident + ); + + if matches!(f.sig.inputs.first(), Some(&FnArg::Receiver(_))) { + println!( + "cargo::rustc-cfg=structure_{module}_trait_{}_method_{}", + trait_.ident, f.sig.ident + ); + } + } + TraitItem::Type(t) => println!( + "cargo::rustc-cfg=structure_{module}_trait_{}_type_{}", + trait_.ident, t.ident + ), + _ => continue, + } + } + } + Item::Union(union_) => { + println!("cargo::rustc-cfg=structure_{module}_union_{}", union_.ident); + + for field in union_.fields.named { + if let Some(field_ident) = field.ident { + println!( + "cargo::rustc-cfg=structure_{module}_union_{}_field_{field_ident}", + union_.ident + ); + } + } + } + _ => continue, + } + } + + Ok(()) +} diff --git a/src/main/resources/templates/rust/test/rust_template_test_macros/.gitignore b/src/main/resources/templates/rust/test/rust_template_test_macros/.gitignore new file mode 100644 index 000000000000..ea8c4bf7f35f --- /dev/null +++ b/src/main/resources/templates/rust/test/rust_template_test_macros/.gitignore @@ -0,0 +1 @@ +/target diff --git a/src/main/resources/templates/rust/test/rust_template_test_macros/Cargo.lock b/src/main/resources/templates/rust/test/rust_template_test_macros/Cargo.lock new file mode 100644 index 000000000000..a1259e414852 --- /dev/null +++ b/src/main/resources/templates/rust/test/rust_template_test_macros/Cargo.lock @@ -0,0 +1,46 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "proc-macro2" +version = "1.0.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rust_template_test_macros" +version = "0.1.0" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc4b9b9bf2add8093d3f2c0204471e951b2285580335de42f9d2534f3ae7a8af" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" diff --git a/src/main/resources/templates/rust/test/rust_template_test_macros/Cargo.toml b/src/main/resources/templates/rust/test/rust_template_test_macros/Cargo.toml new file mode 100644 index 000000000000..625dee26344d --- /dev/null +++ b/src/main/resources/templates/rust/test/rust_template_test_macros/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "rust_template_test_macros" +version = "0.1.0" +edition = "2021" + +[lib] +proc-macro = true + +[dependencies] +quote = "1.0.36" +syn = { version = "2.0.72", features = ["full"] } diff --git a/src/main/resources/templates/rust/test/rust_template_test_macros/src/lib.rs b/src/main/resources/templates/rust/test/rust_template_test_macros/src/lib.rs new file mode 100644 index 000000000000..498fa14d54a1 --- /dev/null +++ b/src/main/resources/templates/rust/test/rust_template_test_macros/src/lib.rs @@ -0,0 +1,346 @@ +//! Compile-time source code reflection +//! +//! Use this for conditional compilation. + +use proc_macro::TokenStream; + +use quote::{format_ident, quote}; +use syn::parse::Parse; +use syn::{parse_macro_input, Ident, Item, ItemFn, Path, Token}; + +trait ToStringLocal { + fn to_string(&self) -> String; +} + +impl ToStringLocal for Path { + fn to_string(&self) -> String { + let segments: Vec<_> = self.segments.iter().map(|s| s.ident.to_string()).collect(); + segments.join("::") + } +} + +#[proc_macro_attribute] +pub fn require_struct(attr: TokenStream, item: TokenStream) -> TokenStream { + require_for_item(attr, item, make_cfg_struct) +} + +#[proc_macro_attribute] +pub fn require_struct_or_fail(attr: TokenStream, item: TokenStream) -> TokenStream { + require_for_function(attr, item, make_cfg_struct) +} + +#[proc_macro_attribute] +pub fn require_trait(attr: TokenStream, item: TokenStream) -> TokenStream { + require_for_item(attr, item, make_cfg_trait) +} + +#[proc_macro_attribute] +pub fn require_trait_or_fail(attr: TokenStream, item: TokenStream) -> TokenStream { + require_for_function(attr, item, make_cfg_trait) +} + +#[proc_macro_attribute] +pub fn require_enum(attr: TokenStream, item: TokenStream) -> TokenStream { + require_for_item(attr, item, make_cfg_enum) +} + +#[proc_macro_attribute] +pub fn require_enum_or_fail(attr: TokenStream, item: TokenStream) -> TokenStream { + require_for_function(attr, item, make_cfg_enum) +} + +#[proc_macro_attribute] +pub fn require_function(attr: TokenStream, item: TokenStream) -> TokenStream { + require_for_item(attr, item, make_cfg_function) +} + +#[proc_macro_attribute] +pub fn require_function_or_fail(attr: TokenStream, item: TokenStream) -> TokenStream { + require_for_function(attr, item, make_cfg_function) +} + +#[proc_macro_attribute] +pub fn require_impl_for_trait(attr: TokenStream, item: TokenStream) -> TokenStream { + require_for_item(attr, item, make_cfg_impl_for_trait) +} + +#[proc_macro_attribute] +pub fn require_impl_for_trait_or_fail(attr: TokenStream, item: TokenStream) -> TokenStream { + require_for_function(attr, item, make_cfg_impl_for_trait) +} + +#[proc_macro_attribute] +pub fn require_impl_function(attr: TokenStream, item: TokenStream) -> TokenStream { + require_for_item(attr, item, make_cfg_impl_function) +} + +#[proc_macro_attribute] +pub fn require_impl_function_or_fail(attr: TokenStream, item: TokenStream) -> TokenStream { + require_for_function(attr, item, make_cfg_impl_function) +} + +#[proc_macro_attribute] +pub fn require_impl_method(attr: TokenStream, item: TokenStream) -> TokenStream { + require_for_item(attr, item, make_cfg_impl_method) +} + +#[proc_macro_attribute] +pub fn require_impl_method_or_fail(attr: TokenStream, item: TokenStream) -> TokenStream { + require_for_function(attr, item, make_cfg_impl_method) +} + +#[proc_macro_attribute] +pub fn require_impl_const(attr: TokenStream, item: TokenStream) -> TokenStream { + require_for_item(attr, item, make_cfg_impl_const) +} + +#[proc_macro_attribute] +pub fn require_impl_const_or_fail(attr: TokenStream, item: TokenStream) -> TokenStream { + require_for_function(attr, item, make_cfg_impl_const) +} + +#[proc_macro_attribute] +pub fn require_impl_type(attr: TokenStream, item: TokenStream) -> TokenStream { + require_for_item(attr, item, make_cfg_impl_type) +} + +#[proc_macro_attribute] +pub fn require_impl_type_or_fail(attr: TokenStream, item: TokenStream) -> TokenStream { + require_for_function(attr, item, make_cfg_impl_type) +} + +#[proc_macro_attribute] +pub fn require_trait_function(attr: TokenStream, item: TokenStream) -> TokenStream { + require_for_item(attr, item, make_cfg_trait_function) +} + +#[proc_macro_attribute] +pub fn require_trait_function_or_fail(attr: TokenStream, item: TokenStream) -> TokenStream { + require_for_function(attr, item, make_cfg_trait_function) +} + +#[proc_macro_attribute] +pub fn require_trait_method(attr: TokenStream, item: TokenStream) -> TokenStream { + require_for_item(attr, item, make_cfg_trait_method) +} + +#[proc_macro_attribute] +pub fn require_trait_method_or_fail(attr: TokenStream, item: TokenStream) -> TokenStream { + require_for_function(attr, item, make_cfg_trait_method) +} + +#[proc_macro_attribute] +pub fn require_trait_const(attr: TokenStream, item: TokenStream) -> TokenStream { + require_for_item(attr, item, make_cfg_trait_const) +} + +#[proc_macro_attribute] +pub fn require_trait_const_or_fail(attr: TokenStream, item: TokenStream) -> TokenStream { + require_for_function(attr, item, make_cfg_trait_const) +} + +#[proc_macro_attribute] +pub fn require_trait_type(attr: TokenStream, item: TokenStream) -> TokenStream { + require_for_item(attr, item, make_cfg_trait_type) +} + +#[proc_macro_attribute] +pub fn require_trait_type_or_fail(attr: TokenStream, item: TokenStream) -> TokenStream { + require_for_function(attr, item, make_cfg_trait_type) +} + +#[proc_macro_attribute] +pub fn require_trait_supertrait(attr: TokenStream, item: TokenStream) -> TokenStream { + require_for_item(attr, item, make_cfg_trait_supertrait) +} + +#[proc_macro_attribute] +pub fn require_trait_supertrait_or_fail(attr: TokenStream, item: TokenStream) -> TokenStream { + require_for_function(attr, item, make_cfg_trait_supertrait) +} + +struct SuperTraitSpec { + module_path: String, + trait_name: String, + supertrait: String, +} + +impl Parse for SuperTraitSpec { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let path: Path = input.parse()?; + input.parse::()?; + let supertrait: Path = input.parse()?; + + let (module_path, trait_name) = split_path(path); + let supertrait = supertrait.segments.last().unwrap().ident.to_string(); + + Ok(SuperTraitSpec { + module_path, + trait_name, + supertrait, + }) + } +} + +impl ToStringLocal for SuperTraitSpec { + fn to_string(&self) -> String { + format!( + "supertrait {} of {}::{}", + self.supertrait, self.module_path, self.trait_name + ) + } +} + +fn require_for_item Ident>( + attr: TokenStream, + item: TokenStream, + make_cfg: F, +) -> TokenStream { + let attribute = parse_macro_input!(attr as A); + let original_item = parse_macro_input!(item as Item); + + let cfg = make_cfg(attribute); + + quote! ( + #[cfg(#cfg)] + #original_item + ) + .into() +} + +fn require_for_function Ident>( + attr: TokenStream, + item: TokenStream, + make_cfg: F, +) -> TokenStream { + let attribute = parse_macro_input!(attr as A); + let original_fn = parse_macro_input!(item as ItemFn); + + let failure_message = format!("missing {}", attribute.to_string()); + + let cfg = make_cfg(attribute); + + let ItemFn { + attrs, + vis, + sig, + block, + } = original_fn; + + quote!( + #(#attrs)* + #vis #sig { + #[cfg(not(#cfg))] + panic!(#failure_message); + #[cfg(#cfg)] + #block + } + ) + .into() +} + +fn make_cfg_struct(path: Path) -> Ident { + let (module_path, struct_name) = split_path(path); + format_ident!("structure_{module_path}_struct_{struct_name}") +} + +fn make_cfg_trait(path: Path) -> Ident { + let (module_path, trait_name) = split_path(path); + format_ident!("structure_{module_path}_trait_{trait_name}") +} + +fn make_cfg_enum(path: Path) -> Ident { + let (module_path, enum_name) = split_path(path); + format_ident!("structure_{module_path}_enum_{enum_name}") +} + +fn make_cfg_function(path: Path) -> Ident { + let (module_path, function_name) = split_path(path); + format_ident!("structure_{module_path}_fn_{function_name}") +} + +fn make_cfg_impl_for_trait(path: Path) -> Ident { + let (module_path, self_type, trait_name) = split_path2(path); + format_ident!("structure_{module_path}_impl_{trait_name}_for_{self_type}") +} + +fn make_cfg_impl_function(path: Path) -> Ident { + let (module_path, self_type, function) = split_path2(path); + format_ident!("structure_{module_path}_impl_{self_type}_fn_{function}") +} + +fn make_cfg_impl_method(path: Path) -> Ident { + let (module_path, self_type, function) = split_path2(path); + format_ident!("structure_{module_path}_impl_{self_type}_method_{function}") +} + +fn make_cfg_impl_const(path: Path) -> Ident { + let (module_path, self_type, const_name) = split_path2(path); + format_ident!("structure_{module_path}_impl_{self_type}_const_{const_name}") +} + +fn make_cfg_impl_type(path: Path) -> Ident { + let (module_path, self_type, type_name) = split_path2(path); + format_ident!("structure_{module_path}_impl_{self_type}_type_{type_name}") +} + +fn make_cfg_trait_function(path: Path) -> Ident { + let (module_path, trait_type, function) = split_path2(path); + format_ident!("structure_{module_path}_trait_{trait_type}_fn_{function}") +} + +fn make_cfg_trait_method(path: Path) -> Ident { + let (module_path, trait_type, function) = split_path2(path); + format_ident!("structure_{module_path}_trait_{trait_type}_method_{function}") +} + +fn make_cfg_trait_const(path: Path) -> Ident { + let (module_path, trait_type, const_name) = split_path2(path); + format_ident!("structure_{module_path}_trait_{trait_type}_const_{const_name}") +} + +fn make_cfg_trait_type(path: Path) -> Ident { + let (module_path, trait_type, type_name) = split_path2(path); + format_ident!("structure_{module_path}_trait_{trait_type}_type_{type_name}") +} + +fn make_cfg_trait_supertrait(spec: SuperTraitSpec) -> Ident { + format_ident!( + "structure_{}_trait_{}_supertrait_{}", + spec.module_path, + spec.trait_name, + spec.supertrait + ) +} + +fn split_path(path: Path) -> (String, String) { + let path_segments: Vec<_> = path.segments.iter().map(|s| &s.ident).collect(); + let (item_name, path_segments) = path_segments.split_last().unwrap(); + + let item_name = item_name.to_string(); + let module_path = path_segments + .iter() + .copied() + .map(Ident::to_string) + .collect::>() + .join("_"); + + (module_path, item_name) +} + +fn split_path2(path: Path) -> (String, String, String) { + let path_segments: Vec<_> = path.segments.iter().map(|s| &s.ident).collect(); + let (item2_name, path_segments) = path_segments.split_last().unwrap(); + let (item1_name, path_segments) = path_segments.split_last().unwrap(); + + let item1_name = item1_name.to_string(); + let item2_name = item2_name.to_string(); + let module_path = path_segments + .iter() + .copied() + .map(Ident::to_string) + .collect::>() + .join("_"); + + (module_path, item1_name, item2_name) +} diff --git a/src/main/resources/templates/rust/test/tests/behavior.rs b/src/main/resources/templates/rust/test/tests/behavior.rs new file mode 100644 index 000000000000..7efd38c20de5 --- /dev/null +++ b/src/main/resources/templates/rust/test/tests/behavior.rs @@ -0,0 +1,90 @@ +use chrono::NaiveDate; +use rust_template_exercise::{bubble_sort::BubbleSort, merge_sort::MergeSort}; + +use rust_template_test_macros::{ + require_impl_function_or_fail, require_impl_method_or_fail, require_struct_or_fail, + require_trait, require_trait_supertrait_or_fail, +}; + +#[require_trait(sort_strategy::SortStrategy)] +use rust_template_exercise::sort_strategy::SortStrategy; + +// We can't use the opt variants because the Option methods aren't const yet +#[allow(deprecated)] +const DATES_UNORDERED: [NaiveDate; 4] = [ + NaiveDate::from_ymd(2018, 11, 8), + NaiveDate::from_ymd(2017, 4, 15), + NaiveDate::from_ymd(2016, 2, 15), + NaiveDate::from_ymd(2017, 9, 15), +]; +#[allow(deprecated)] +const DATES_ORDERED: [NaiveDate; 4] = [ + NaiveDate::from_ymd(2016, 2, 15), + NaiveDate::from_ymd(2017, 4, 15), + NaiveDate::from_ymd(2017, 9, 15), + NaiveDate::from_ymd(2018, 11, 8), +]; + +#[test] +fn test_bubble_sort() { + let bubble_sort = BubbleSort; + let mut dates = DATES_UNORDERED; + bubble_sort.perform_sort(&mut dates); + assert_eq!(dates, DATES_ORDERED, "BubbleSort does not sort correctly"); +} + +#[test] +fn test_merge_sort() { + let merge_sort = MergeSort; + let mut dates = DATES_UNORDERED; + merge_sort.perform_sort(&mut dates); + assert_eq!(dates, DATES_ORDERED, "MergeSort does not sort correctly"); +} + +#[test] +#[require_struct_or_fail(context::Context)] +#[require_struct_or_fail(policy::Policy)] +#[require_trait_supertrait_or_fail(sort_strategy::SortStrategy : Any)] +#[require_impl_method_or_fail(context::Context::sort_algorithm)] +#[require_impl_function_or_fail(context::Context::new)] +#[require_impl_function_or_fail(policy::Policy::new)] +fn test_use_merge_sort_for_big_list() { + let context = std::cell::RefCell::new(rust_template_exercise::context::Context::new()); + let mut policy = rust_template_exercise::policy::Policy::new(&context); + + let data = [NaiveDate::default(); 20]; + policy.configure(&data); + + let context = context.borrow(); + let sort_strategy = context.sort_algorithm(); + + assert_eq!( + sort_strategy.type_id(), + std::any::TypeId::of::(), + "The sort algorithm of Context was not MergeSort for a list with more than 10 dates." + ); +} + +#[test] +#[require_struct_or_fail(context::Context)] +#[require_struct_or_fail(policy::Policy)] +#[require_trait_supertrait_or_fail(sort_strategy::SortStrategy : Any)] +#[require_impl_method_or_fail(context::Context::sort_algorithm)] +#[require_impl_function_or_fail(context::Context::new)] +#[require_impl_function_or_fail(policy::Policy::new)] +fn test_use_bubble_sort_for_small_list() { + let context = std::cell::RefCell::new(rust_template_exercise::context::Context::new()); + let mut policy = rust_template_exercise::policy::Policy::new(&context); + + let data = [NaiveDate::default(); 10]; + policy.configure(&data); + + let context = context.borrow(); + let sort_strategy = context.sort_algorithm(); + + assert_eq!( + sort_strategy.type_id(), + std::any::TypeId::of::(), + "The sort algorithm of Context was not BubbleSort for a list with less or equal than 10 dates." + ); +} diff --git a/src/main/resources/templates/rust/test/tests/structural.rs b/src/main/resources/templates/rust/test/tests/structural.rs new file mode 100644 index 000000000000..70b0d19c9131 --- /dev/null +++ b/src/main/resources/templates/rust/test/tests/structural.rs @@ -0,0 +1,79 @@ +mod structural_helpers; + +use structural_helpers::*; + +#[test] +fn test_sort_strategy_trait() { + let ast = parse_file("./assignment/src/sort_strategy.rs"); + check_trait_names(&ast.items, ["SortStrategy"]) + .unwrap_or_else(|name| panic!("A trait named \"{name}\" should be defined")); +} + +#[test] +fn test_sort_strategy_supertrait() { + let ast = parse_file("./assignment/src/sort_strategy.rs"); + let sort_strategy = find_trait(&ast.items, "SortStrategy") + .expect("A trait named \"SortStrategy\" should be defined"); + check_trait_supertrait(sort_strategy, "Any") + .unwrap_or_else(|_| panic!("SortStrategy should have \"Any\" as a supertrait")); +} + +#[test] +fn test_sort_strategy_methods() { + let ast = parse_file("./assignment/src/sort_strategy.rs"); + let sort_strategy = find_trait(&ast.items, "SortStrategy") + .expect("A trait named \"SortStrategy\" should be defined"); + check_trait_function_names(&sort_strategy.items, ["perform_sort"]) + .unwrap_or_else(|name| panic!("SortStrategy should define the function \"{name}\"")); +} + +#[test] +fn test_context_fields() { + let ast = parse_file("./assignment/src/context.rs"); + let context = + find_struct(&ast.items, "Context").expect("A struct named \"Context\" should be defined"); + check_struct_field_names(&context.fields, ["sort_algorithm"]) + .unwrap_or_else(|name| panic!("Context should define the field \"{name}\"")); +} + +#[test] +fn test_context_methods() { + let ast = parse_file("./assignment/src/context.rs"); + let context_impl = + find_impl(&ast.items, "Context").expect("SortStrategy should implement functions"); + check_impl_function_names(&context_impl.items, ["new", "sort", "sort_algorithm"]) + .unwrap_or_else(|name| panic!("Context should implement the function \"{name}\"")); +} + +#[test] +fn test_policy_fields() { + let ast = parse_file("./assignment/src/policy.rs"); + let policy = + find_struct(&ast.items, "Policy").expect("A struct named \"Policy\" should be defined"); + check_struct_field_names(&policy.fields, ["context"]) + .unwrap_or_else(|name| panic!("Policy should define the field \"{name}\"")); +} + +#[test] +fn test_policy_methods() { + let ast = parse_file("./assignment/src/policy.rs"); + let policy_impl = find_impl(&ast.items, "Policy").expect("Policy should implement functions"); + check_impl_function_names(&policy_impl.items, ["new", "configure"]) + .unwrap_or_else(|name| panic!("Policy should implement the function \"{name}\"")); +} + +#[test] +fn test_bubble_sort_struct() { + let ast = parse_file("./assignment/src/bubble_sort.rs"); + find_struct(&ast.items, "BubbleSort").expect("A struct named \"BubbleSort\" should be defined"); + find_impl_for(&ast.items, "BubbleSort", "SortStrategy") + .expect("BubbleSort should implement the trait \"SortStrategy\""); +} + +#[test] +fn test_merge_sort_struct() { + let ast = parse_file("./assignment/src/merge_sort.rs"); + find_struct(&ast.items, "MergeSort").expect("A struct named \"MergeSort\" should be defined"); + find_impl_for(&ast.items, "MergeSort", "SortStrategy") + .expect("MergeSort should implement the trait \"SortStrategy\""); +} diff --git a/src/main/resources/templates/rust/test/tests/structural_helpers/mod.rs b/src/main/resources/templates/rust/test/tests/structural_helpers/mod.rs new file mode 100644 index 000000000000..493d043f6d5c --- /dev/null +++ b/src/main/resources/templates/rust/test/tests/structural_helpers/mod.rs @@ -0,0 +1,207 @@ +//! Run-time source code reflection +//! +//! Use this for flexible parsing and computed names. +#![allow(dead_code)] + +use std::io::Read; + +use syn::{ + Fields, ImplItem, ImplItemFn, + Item::{self, Impl, Struct, Trait}, + ItemImpl, ItemStruct, ItemTrait, TraitItem, TraitItemFn, TypeParamBound, +}; + +pub fn check_struct_names<'a, I: IntoIterator>( + items: &[Item], + names: I, +) -> Result<(), &'a str> { + for name in names { + find_struct(items, name).ok_or(name)?; + } + Ok(()) +} + +pub fn find_struct<'a>(items: &'a [Item], name: &str) -> Option<&'a ItemStruct> { + find_by_name( + items, + name, + |i| match i { + Struct(s) => Some(s), + _ => None, + }, + |s| &s.ident, + ) +} + +pub fn check_struct_field_names<'a, I: IntoIterator>( + fields: &Fields, + names: I, +) -> Result<(), &'a str> { + let p = match fields { + Fields::Named(f) => &f.named, + _ => panic!("The struct should have named fields"), + }; + + let field_names: Vec<_> = p.iter().map(|f| f.ident.as_ref().unwrap()).collect(); + + for name in names { + field_names + .iter() + .copied() + .find(|&n| n == name) + .ok_or(name)?; + } + Ok(()) +} + +pub fn find_impl<'a>(items: &'a [Item], name: &str) -> Option<&'a ItemImpl> { + items.iter().find_map(|i| { + let im = match i { + Impl(im) => im, + _ => return None, + }; + let self_name = match im.self_ty.as_ref() { + syn::Type::Path(p) => &p.path.segments.last().unwrap().ident, + _ => return None, + }; + if im.trait_.is_some() { + return None; + } + + if self_name != name { + return None; + } + + Some(im) + }) +} + +pub fn find_impl_for<'a>(items: &'a [Item], name: &str, for_trait: &str) -> Option<&'a ItemImpl> { + items.iter().find_map(|i| { + let im = match i { + Impl(im) => im, + _ => return None, + }; + let self_name = match im.self_ty.as_ref() { + syn::Type::Path(p) => &p.path.segments.last().unwrap().ident, + _ => return None, + }; + let trait_name = match im.trait_.as_ref() { + Some((_, path, _)) => &path.segments.last().unwrap().ident, + _ => return None, + }; + + if self_name != name || trait_name != for_trait { + return None; + } + + Some(im) + }) +} + +pub fn check_impl_function_names<'a, I: IntoIterator>( + items: &[ImplItem], + names: I, +) -> Result<(), &'a str> { + for name in names { + find_impl_function(items, name).ok_or(name)?; + } + Ok(()) +} + +pub fn find_impl_function<'a>(items: &'a [ImplItem], name: &str) -> Option<&'a ImplItemFn> { + find_by_name( + items, + name, + |i| match i { + ImplItem::Fn(f) => Some(f), + _ => None, + }, + |f| &f.sig.ident, + ) +} + +pub fn check_trait_names<'a, I: IntoIterator>( + items: &[Item], + names: I, +) -> Result<(), &'a str> { + for name in names { + find_trait(items, name).ok_or(name)?; + } + Ok(()) +} + +pub fn find_trait<'a>(items: &'a [Item], name: &str) -> Option<&'a ItemTrait> { + find_by_name( + items, + name, + |i| match i { + Trait(t) => Some(t), + _ => None, + }, + |t| &t.ident, + ) +} + +pub fn check_trait_function_names<'a, I: IntoIterator>( + items: &[TraitItem], + names: I, +) -> Result<(), &'a str> { + for name in names { + find_trait_function(items, name).ok_or(name)?; + } + Ok(()) +} + +pub fn find_trait_function<'a>(items: &'a [TraitItem], name: &str) -> Option<&'a TraitItemFn> { + find_by_name( + items, + name, + |i| match i { + TraitItem::Fn(f) => Some(f), + _ => None, + }, + |f| &f.sig.ident, + ) +} + +pub fn check_trait_supertrait(trait_: &ItemTrait, supertrait_name: &str) -> Result<(), ()> { + find_by_name( + &trait_.supertraits, + supertrait_name, + |s| match s { + TypeParamBound::Trait(t) => Some(t), + _ => None, + }, + |t| &t.path.segments.last().unwrap().ident, + ) + .ok_or(())?; + Ok(()) +} + +fn find_by_name< + 'a, + Item, + I: IntoIterator, + M: FnMut(Item) -> Option, + R, + N: FnMut(&R) -> C, + C: PartialEq<&'a str>, +>( + items: I, + name: &'a str, + item_matcher: M, + mut name_getter: N, +) -> Option { + items + .into_iter() + .filter_map(item_matcher) + .find(|i| name_getter(i) == name) +} + +pub fn parse_file>(path: P) -> syn::File { + let mut file = std::fs::File::open(path).unwrap(); + let mut content = String::new(); + file.read_to_string(&mut content).unwrap(); + syn::parse_file(&content).unwrap() +} diff --git a/src/main/webapp/app/admin/legal/legal-document-update.component.ts b/src/main/webapp/app/admin/legal/legal-document-update.component.ts index cbe5aafc7cd5..63541a0c97dc 100644 --- a/src/main/webapp/app/admin/legal/legal-document-update.component.ts +++ b/src/main/webapp/app/admin/legal/legal-document-update.component.ts @@ -1,14 +1,13 @@ import { AfterContentChecked, ChangeDetectorRef, Component, OnInit, ViewChild } from '@angular/core'; import { faBan, faCheckCircle, faCircleNotch, faExclamationTriangle, faSave } from '@fortawesome/free-solid-svg-icons'; import { LegalDocumentService } from 'app/shared/service/legal-document.service'; -import { MarkdownEditorHeight } from 'app/shared/markdown-editor/markdown-editor.component'; import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; import { UnsavedChangesWarningComponent } from 'app/admin/legal/unsaved-changes-warning/unsaved-changes-warning.component'; import { LegalDocument, LegalDocumentLanguage, LegalDocumentType } from 'app/entities/legal-document.model'; import { ActivatedRoute } from '@angular/router'; import { Observable, tap } from 'rxjs'; import { JhiLanguageHelper } from 'app/core/language/language.helper'; -import { MarkdownEditorMonacoComponent } from 'app/shared/markdown-editor/monaco/markdown-editor-monaco.component'; +import { MarkdownEditorHeight, MarkdownEditorMonacoComponent } from 'app/shared/markdown-editor/monaco/markdown-editor-monaco.component'; @Component({ selector: 'jhi-privacy-statement-update-component', diff --git a/src/main/webapp/app/core/auth/account.service.ts b/src/main/webapp/app/core/auth/account.service.ts index 81e0c074557b..1b6a82c3a0a0 100644 --- a/src/main/webapp/app/core/auth/account.service.ts +++ b/src/main/webapp/app/core/auth/account.service.ts @@ -12,7 +12,7 @@ import { StudentParticipation } from 'app/entities/participation/student-partici import { Exercise, getCourseFromExercise } from 'app/entities/exercise.model'; import { Authority } from 'app/shared/constants/authority.constants'; import { TranslateService } from '@ngx-translate/core'; -import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; +import { EntityResponseType } from 'app/complaints/complaint.service'; export interface IAccountService { save: (account: any) => Observable>; @@ -39,7 +39,6 @@ export class AccountService implements IAccountService { private authenticated = false; private authenticationState = new BehaviorSubject(undefined); private prefilledUsernameValue?: string; - private versionControlAccessTokenRequired: boolean; constructor( private translateService: TranslateService, @@ -47,7 +46,6 @@ export class AccountService implements IAccountService { private http: HttpClient, private websocketService: JhiWebsocketService, private featureToggleService: FeatureToggleService, - private profileService: ProfileService, ) {} get userIdentity() { @@ -137,19 +135,10 @@ export class AccountService implements IAccountService { this.userIdentity = undefined; } - if (this.versionControlAccessTokenRequired === undefined) { - this.profileService.getProfileInfo().subscribe((profileInfo) => { - this.versionControlAccessTokenRequired = profileInfo.useVersionControlAccessToken ?? false; - }); - } - // check and see if we have retrieved the userIdentity data from the server. // if we have, reuse it by immediately resolving if (this.userIdentity) { - // in case a token is required but not present in the user, we cannot simply return the cached object - if (!this.versionControlAccessTokenRequired || this.userIdentity.vcsAccessToken !== undefined) { - return Promise.resolve(this.userIdentity); - } + return Promise.resolve(this.userIdentity); } // retrieve the userIdentity data from the server, update the identity object, and then resolve. @@ -336,7 +325,7 @@ export class AccountService implements IAccountService { if (this.userIdentity) { this.userIdentity.sshPublicKey = sshPublicKey; } - return this.http.put('api/users/sshpublickey', sshPublicKey); + return this.http.put('api/account/ssh-public-key', sshPublicKey); } /** @@ -346,7 +335,24 @@ export class AccountService implements IAccountService { if (this.userIdentity) { this.userIdentity.sshPublicKey = undefined; } - return this.http.delete('api/users/sshpublickey'); + return this.http.delete('api/account/ssh-public-key'); + } + + /** + * Sends a request to the server to delete the user's current vcsAccessToken + */ + deleteUserVcsAccessToken(): Observable { + return this.http.delete('api/account/user-vcs-access-token'); + } + + /** + * Sends a request to the server to create a new vcsAccessToken for the user + * + * @param expiryDate The expiry date which should get set for the vcsAccessToken + */ + addNewVcsAccessToken(expiryDate: string): Observable { + const params = new HttpParams().set('expiryDate', expiryDate); + return this.http.put('api/account/user-vcs-access-token', null, { observe: 'response', params }); } /** @@ -357,7 +363,7 @@ export class AccountService implements IAccountService { */ getVcsAccessToken(participationId: number): Observable> { const params = new HttpParams().set('participationId', participationId); - return this.http.get('api/users/vcsToken', { observe: 'response', params, responseType: 'text' as 'json' }); + return this.http.get('api/account/participation-vcs-access-token', { observe: 'response', params, responseType: 'text' as 'json' }); } /** @@ -368,6 +374,6 @@ export class AccountService implements IAccountService { */ createVcsAccessToken(participationId: number): Observable> { const params = new HttpParams().set('participationId', participationId); - return this.http.put('api/users/vcsToken', null, { observe: 'response', params, responseType: 'text' as 'json' }); + return this.http.put('api/account/participation-vcs-access-token', null, { observe: 'response', params, responseType: 'text' as 'json' }); } } diff --git a/src/main/webapp/app/core/theme/theme.service.ts b/src/main/webapp/app/core/theme/theme.service.ts index a6fa87c0c01a..507b355b11fe 100644 --- a/src/main/webapp/app/core/theme/theme.service.ts +++ b/src/main/webapp/app/core/theme/theme.service.ts @@ -11,24 +11,20 @@ export const THEME_OVERRIDE_ID = 'artemis-theme-override'; * If you add new themes, make sure to adapt the theme switch component which currently only supports two themes. */ export class Theme { - public static readonly LIGHT = new Theme('LIGHT', true, undefined, faSun, 'chrome', 'dreamweaver'); - public static readonly DARK = new Theme('DARK', false, 'theme-dark.css', faMoon, 'dracula', 'dracula'); + public static readonly LIGHT = new Theme('LIGHT', true, undefined, faSun); + public static readonly DARK = new Theme('DARK', false, 'theme-dark.css', faMoon); - private constructor(identifier: string, isDefault: boolean, fileName: string | undefined, icon: IconDefinition, markdownAceTheme: string, codeAceTheme: string) { + private constructor(identifier: string, isDefault: boolean, fileName: string | undefined, icon: IconDefinition) { this.identifier = identifier; this.isDefault = isDefault; this.fileName = fileName; this.icon = icon; - this.markdownAceTheme = markdownAceTheme; - this.codeAceTheme = codeAceTheme; } public readonly identifier: string; public readonly isDefault: boolean; public readonly fileName: string | undefined; public readonly icon: IconDefinition; - public readonly markdownAceTheme: string; - public readonly codeAceTheme: string; /** * Returns an array with all available themes. diff --git a/src/main/webapp/app/core/user/user.model.ts b/src/main/webapp/app/core/user/user.model.ts index 0c30062efbd0..816cf4fc9a9c 100644 --- a/src/main/webapp/app/core/user/user.model.ts +++ b/src/main/webapp/app/core/user/user.model.ts @@ -14,6 +14,7 @@ export class User extends Account { public visibleRegistrationNumber?: string; public password?: string; public vcsAccessToken?: string; + public vcsAccessTokenExpiryDate?: string; public sshPublicKey?: string; public irisAccepted?: dayjs.Dayjs; @@ -35,6 +36,7 @@ export class User extends Account { password?: string, imageUrl?: string, vcsAccessToken?: string, + vcsAccessTokenExpiryDate?: string, sshPublicKey?: string, irisAccepted?: dayjs.Dayjs, ) { @@ -48,6 +50,7 @@ export class User extends Account { this.lastNotificationRead = lastNotificationRead; this.password = password; this.vcsAccessToken = vcsAccessToken; + this.vcsAccessTokenExpiryDate = vcsAccessTokenExpiryDate; this.sshPublicKey = sshPublicKey; this.irisAccepted = irisAccepted; } diff --git a/src/main/webapp/app/course/competencies/course-competency.service.ts b/src/main/webapp/app/course/competencies/course-competency.service.ts index 65094f9d1a90..04403d48db93 100644 --- a/src/main/webapp/app/course/competencies/course-competency.service.ts +++ b/src/main/webapp/app/course/competencies/course-competency.service.ts @@ -20,6 +20,7 @@ import { ExerciseService } from 'app/exercises/shared/exercise/exercise.service' import { Prerequisite } from 'app/entities/prerequisite.model'; import { LectureUnitService } from 'app/lecture/lecture-unit/lecture-unit-management/lectureUnit.service'; import { AccountService } from 'app/core/auth/account.service'; +import { CompetencyRecommendation } from 'app/course/competencies/generate-competencies/generate-competencies.component'; type EntityResponseType = HttpResponse; type EntityArrayResponseType = HttpResponse; @@ -105,8 +106,14 @@ export class CourseCompetencyService { ); } - generateCompetenciesFromCourseDescription(courseDescription: string, courseId: number): Observable { - return this.httpClient.post(`${this.resourceURL}/courses/${courseId}/course-competencies/generate-from-description`, courseDescription, { + // triggers the generation of competencies from the given course description + // the generated competencies are returned asynchronously over the websocket on the topic /topic/iris/competencies/{courseId} + generateCompetenciesFromCourseDescription(courseId: number, courseDescription: string, currentCompetencies: CompetencyRecommendation[]): Observable> { + const params = { + courseDescription: courseDescription, + currentCompetencies: currentCompetencies, + }; + return this.httpClient.post(`${this.resourceURL}/courses/${courseId}/course-competencies/generate-from-description`, params, { observe: 'response', }); } diff --git a/src/main/webapp/app/course/competencies/generate-competencies/course-description-form.component.ts b/src/main/webapp/app/course/competencies/generate-competencies/course-description-form.component.ts index b6cec6cdec66..a466747c670b 100644 --- a/src/main/webapp/app/course/competencies/generate-competencies/course-description-form.component.ts +++ b/src/main/webapp/app/course/competencies/generate-competencies/course-description-form.component.ts @@ -9,6 +9,7 @@ import { ButtonType } from 'app/shared/components/button.component'; }) export class CourseDescriptionFormComponent implements OnInit { @Input() isLoading = false; + @Input() placeholder = ''; @Output() formSubmitted: EventEmitter = new EventEmitter(); form: FormGroup<{ courseDescription: FormControl }>; @@ -26,10 +27,14 @@ export class CourseDescriptionFormComponent implements OnInit { ngOnInit(): void { this.form = this.formBuilder.group({ - courseDescription: ['', [Validators.required, Validators.minLength(this.DESCRIPTION_MIN), Validators.maxLength(this.DESCRIPTION_MAX)]], + courseDescription: [this.placeholder, [Validators.required, Validators.minLength(this.DESCRIPTION_MIN), Validators.maxLength(this.DESCRIPTION_MAX)]], }); } + setCourseDescription(description: string) { + this.form.controls.courseDescription.setValue(description); + } + /** * Sends event to parent to handle submission */ diff --git a/src/main/webapp/app/course/competencies/generate-competencies/generate-competencies.component.ts b/src/main/webapp/app/course/competencies/generate-competencies/generate-competencies.component.ts index 74c6d6891d3f..9501484fe9b5 100644 --- a/src/main/webapp/app/course/competencies/generate-competencies/generate-competencies.component.ts +++ b/src/main/webapp/app/course/competencies/generate-competencies/generate-competencies.component.ts @@ -1,4 +1,4 @@ -import { Component, HostListener, OnInit } from '@angular/core'; +import { Component, HostListener, OnInit, ViewChild } from '@angular/core'; import { CompetencyService } from 'app/course/competencies/competency.service'; import { AlertService } from 'app/core/util/alert.service'; import { onError } from 'app/shared/util/global.utils'; @@ -8,17 +8,21 @@ import { ActivatedRoute, Router } from '@angular/router'; import { faBan, faSave, faTimes } from '@fortawesome/free-solid-svg-icons'; import { FormArray, FormBuilder, FormControl, FormGroup } from '@angular/forms'; import { ButtonType } from 'app/shared/components/button.component'; -import { finalize } from 'rxjs/operators'; import { ComponentCanDeactivate } from 'app/shared/guard/can-deactivate.model'; import { ConfirmAutofocusModalComponent } from 'app/shared/components/confirm-autofocus-modal.component'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { Observable, firstValueFrom, map } from 'rxjs'; import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; import { TranslateService } from '@ngx-translate/core'; import { DocumentationType } from 'app/shared/components/documentation-button/documentation-button.component'; +import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { IrisStageDTO, IrisStageStateDTO } from 'app/entities/iris/iris-stage-dto.model'; import { CourseCompetencyService } from 'app/course/competencies/course-competency.service'; import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; import { ArtemisCompetenciesModule } from 'app/course/competencies/competency.module'; +import { CourseManagementService } from 'app/course/manage/course-management.service'; +import { CourseDescriptionFormComponent } from 'app/course/competencies/generate-competencies/course-description-form.component'; export type CompetencyFormControlsWithViewed = { competency: FormGroup; @@ -31,6 +35,17 @@ export type CompetencyFormControls = { taxonomy: FormControl; }; +export type CompetencyRecommendation = { + title?: string; + description?: string; + taxonomy?: CompetencyTaxonomy; +}; + +type CompetencyGenerationStatusUpdate = { + stages: IrisStageDTO[]; + result?: CompetencyRecommendation[]; +}; + @Component({ selector: 'jhi-generate-competencies', templateUrl: './generate-competencies.component.html', @@ -38,6 +53,8 @@ export type CompetencyFormControls = { imports: [ArtemisSharedCommonModule, ArtemisSharedComponentModule, ArtemisCompetenciesModule], }) export class GenerateCompetenciesComponent implements OnInit, ComponentCanDeactivate { + @ViewChild(CourseDescriptionFormComponent) courseDescriptionForm: CourseDescriptionFormComponent; + courseId: number; isLoading = false; submitted: boolean = false; @@ -53,6 +70,7 @@ export class GenerateCompetenciesComponent implements OnInit, ComponentCanDeacti readonly documentationType: DocumentationType = 'GenerateCompetencies'; constructor( + private courseManagementService: CourseManagementService, private courseCompetencyService: CourseCompetencyService, private competencyService: CompetencyService, private alertService: AlertService, @@ -62,11 +80,15 @@ export class GenerateCompetenciesComponent implements OnInit, ComponentCanDeacti private modalService: NgbModal, private artemisTranslatePipe: ArtemisTranslatePipe, private translateService: TranslateService, + private jhiWebsocketService: JhiWebsocketService, ) {} ngOnInit(): void { this.activatedRoute.params.subscribe((params) => { this.courseId = Number(params['courseId']); + firstValueFrom(this.courseManagementService.find(this.courseId)) + .then((course) => this.courseDescriptionForm.setCourseDescription(course.body?.description ?? '')) + .catch((res: HttpErrorResponse) => onError(this.alertService, res)); }); } @@ -76,24 +98,61 @@ export class GenerateCompetenciesComponent implements OnInit, ComponentCanDeacti */ getCompetencyRecommendations(courseDescription: string) { this.isLoading = true; - this.courseCompetencyService - .generateCompetenciesFromCourseDescription(courseDescription, this.courseId) - .pipe( - finalize(() => { + this.getCurrentCompetencies().subscribe((currentCompetencies) => { + this.courseCompetencyService.generateCompetenciesFromCourseDescription(this.courseId, courseDescription, currentCompetencies).subscribe({ + next: () => { + const websocketTopic = `/user/topic/iris/competencies/${this.courseId}`; + this.jhiWebsocketService.subscribe(websocketTopic); + this.jhiWebsocketService.receive(websocketTopic).subscribe({ + next: (update: CompetencyGenerationStatusUpdate) => { + if (update.result) { + for (const competency of update.result) { + this.addCompetencyToForm(competency); + } + } + if (update.stages.every((stage) => stage.state === IrisStageStateDTO.DONE)) { + this.alertService.success('artemisApp.competency.generate.courseDescription.success', { noOfCompetencies: update.result?.length }); + } else if (update.stages.some((stage) => stage.state === IrisStageStateDTO.ERROR)) { + this.alertService.warning('artemisApp.competency.generate.courseDescription.warning'); + } + if (update.stages.every((stage) => stage.state !== IrisStageStateDTO.NOT_STARTED && stage.state !== IrisStageStateDTO.IN_PROGRESS)) { + this.jhiWebsocketService.unsubscribe(websocketTopic); + this.isLoading = false; + } + }, + error: (res: HttpErrorResponse) => { + onError(this.alertService, res); + this.jhiWebsocketService.unsubscribe(websocketTopic); + this.isLoading = false; + }, + }); + }, + error: (res: HttpErrorResponse) => { + onError(this.alertService, res); this.isLoading = false; - }), - ) - .subscribe({ - next: (res) => { - if (res.body?.length && res.body.length > 0) { - this.alertService.success('artemisApp.competency.generate.courseDescription.success', { noOfCompetencies: res.body.length }); - res.body?.forEach((competency) => this.addCompetencyToForm(competency)); - } else { - this.alertService.warning('artemisApp.competency.generate.courseDescription.warning'); - } }, - error: (res: HttpErrorResponse) => onError(this.alertService, res), }); + }); + } + + /** + * Returns the title, description, and taxonomy of all current competencies saved in this course, + * and the competency recommendations that are currently in the form. + * @private + */ + private getCurrentCompetencies(): Observable { + const currentCompetencySuggestions = this.competencies.getRawValue().map((c) => c.competency); + const courseCompetenciesObservable = this.courseCompetencyService.getAllForCourse(this.courseId); + if (courseCompetenciesObservable) { + return courseCompetenciesObservable.pipe( + map((competencies) => competencies.body?.map((c) => ({ title: c.title, description: c.description, taxonomy: c.taxonomy }))), + map((competencies) => currentCompetencySuggestions.concat(competencies ?? [])), + ); + } + return new Observable((subscriber) => { + subscriber.next(currentCompetencySuggestions); + subscriber.complete(); + }); } /** @@ -101,7 +160,7 @@ export class GenerateCompetenciesComponent implements OnInit, ComponentCanDeacti * @param competency * @private */ - private addCompetencyToForm(competency: Competency) { + private addCompetencyToForm(competency: CompetencyRecommendation) { const formGroup: FormGroup = this.formBuilder.nonNullable.group({ competency: this.formBuilder.nonNullable.group({ title: [competency.title], @@ -150,7 +209,7 @@ export class GenerateCompetenciesComponent implements OnInit, ComponentCanDeacti * Saves the competency recommendations as competencies and navigates back */ save() { - const competenciesToSave = this.competencies.getRawValue().map((c) => c.competency as Competency); + const competenciesToSave = this.competencies.getRawValue().map((c) => Object.assign(new Competency(), c.competency)); this.competencyService.createBulk(competenciesToSave, this.courseId).subscribe({ next: () => { this.submitted = true; diff --git a/src/main/webapp/app/course/learning-paths/components/learning-path-lecture-unit/learning-path-lecture-unit.component.html b/src/main/webapp/app/course/learning-paths/components/learning-path-lecture-unit/learning-path-lecture-unit.component.html index 10feb00946b8..2341a771ae26 100644 --- a/src/main/webapp/app/course/learning-paths/components/learning-path-lecture-unit/learning-path-lecture-unit.component.html +++ b/src/main/webapp/app/course/learning-paths/components/learning-path-lecture-unit/learning-path-lecture-unit.component.html @@ -1,4 +1,4 @@ -@if (isLectureUnitLoading()) { +@if (isLoading()) {

diff --git a/src/main/webapp/app/course/learning-paths/components/learning-path-lecture-unit/learning-path-lecture-unit.component.ts b/src/main/webapp/app/course/learning-paths/components/learning-path-lecture-unit/learning-path-lecture-unit.component.ts index a7cd72938c93..c927e2a4532e 100644 --- a/src/main/webapp/app/course/learning-paths/components/learning-path-lecture-unit/learning-path-lecture-unit.component.ts +++ b/src/main/webapp/app/course/learning-paths/components/learning-path-lecture-unit/learning-path-lecture-unit.component.ts @@ -1,12 +1,11 @@ -import { Component, InputSignal, Signal, WritableSignal, inject, input, signal } from '@angular/core'; +import { Component, InputSignal, WritableSignal, effect, inject, input, signal } from '@angular/core'; import { LectureUnitService } from 'app/lecture/lecture-unit/lecture-unit-management/lectureUnit.service'; import { AlertService } from 'app/core/util/alert.service'; import { LectureUnit, LectureUnitType } from 'app/entities/lecture-unit/lectureUnit.model'; import { ArtemisLectureUnitsModule } from 'app/overview/course-lectures/lecture-units.module'; import { LectureUnitCompletionEvent } from 'app/overview/course-lectures/course-lecture-details.component'; import { LearningPathNavigationService } from 'app/course/learning-paths/services/learning-path-navigation.service'; -import { Observable, lastValueFrom, switchMap } from 'rxjs'; -import { toObservable, toSignal } from '@angular/core/rxjs-interop'; +import { lastValueFrom } from 'rxjs'; import { ArtemisSharedModule } from 'app/shared/shared.module'; import { VideoUnitComponent } from 'app/overview/course-lectures/video-unit/video-unit.component'; import { TextUnitComponent } from 'app/overview/course-lectures/text-unit/text-unit.component'; @@ -27,18 +26,22 @@ export class LearningPathLectureUnitComponent { private readonly alertService: AlertService = inject(AlertService); readonly lectureUnitId: InputSignal = input.required(); - readonly isLectureUnitLoading: WritableSignal = signal(false); - private readonly lectureUnit$: Observable = toObservable(this.lectureUnitId).pipe(switchMap((lectureUnitId) => this.getLectureUnit(lectureUnitId))); - readonly lectureUnit: Signal = toSignal(this.lectureUnit$); + readonly isLoading: WritableSignal = signal(false); + readonly lectureUnit = signal(undefined); - async getLectureUnit(lectureUnitId: number): Promise { + constructor() { + effect(() => this.loadLectureUnit(this.lectureUnitId()), { allowSignalWrites: true }); + } + + async loadLectureUnit(lectureUnitId: number): Promise { try { - this.isLectureUnitLoading.set(true); - return await lastValueFrom(this.lectureUnitService.getLectureUnitById(lectureUnitId)); + this.isLoading.set(true); + const lectureUnit = await lastValueFrom(this.lectureUnitService.getLectureUnitById(lectureUnitId)); + this.lectureUnit.set(lectureUnit); } catch (error) { this.alertService.error(error); } finally { - this.isLectureUnitLoading.set(false); + this.isLoading.set(false); } } diff --git a/src/main/webapp/app/course/learning-paths/components/learning-path-nav-overview-learning-objects/learning-path-nav-overview-learning-objects.component.ts b/src/main/webapp/app/course/learning-paths/components/learning-path-nav-overview-learning-objects/learning-path-nav-overview-learning-objects.component.ts index 15589bdb24bc..97609dd46c27 100644 --- a/src/main/webapp/app/course/learning-paths/components/learning-path-nav-overview-learning-objects/learning-path-nav-overview-learning-objects.component.ts +++ b/src/main/webapp/app/course/learning-paths/components/learning-path-nav-overview-learning-objects/learning-path-nav-overview-learning-objects.component.ts @@ -1,4 +1,4 @@ -import { Component, InputSignal, OnInit, OutputEmitterRef, Signal, WritableSignal, computed, inject, input, output, signal } from '@angular/core'; +import { Component, InputSignal, OutputEmitterRef, Signal, WritableSignal, computed, effect, inject, input, output, signal, untracked } from '@angular/core'; import { ArtemisSharedModule } from 'app/shared/shared.module'; import { AlertService } from 'app/core/util/alert.service'; import { LearningPathApiService } from 'app/course/learning-paths/services/learning-path-api.service'; @@ -15,7 +15,7 @@ import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'; templateUrl: './learning-path-nav-overview-learning-objects.component.html', styleUrl: './learning-path-nav-overview-learning-objects.component.scss', }) -export class LearningPathNavOverviewLearningObjectsComponent implements OnInit { +export class LearningPathNavOverviewLearningObjectsComponent { protected readonly faCheckCircle: IconDefinition = faCheckCircle; protected readonly faLock: IconDefinition = faLock; @@ -38,8 +38,13 @@ export class LearningPathNavOverviewLearningObjectsComponent implements OnInit { readonly onLearningObjectSelected: OutputEmitterRef = output(); - ngOnInit(): void { - this.loadLearningObjects(); + constructor() { + effect( + () => { + untracked(async () => await this.loadLearningObjects()); + }, + { allowSignalWrites: true }, + ); } async loadLearningObjects(): Promise { @@ -57,9 +62,9 @@ export class LearningPathNavOverviewLearningObjectsComponent implements OnInit { } } - selectLearningObject(learningObject: LearningPathNavigationObjectDTO): void { + async selectLearningObject(learningObject: LearningPathNavigationObjectDTO): Promise { if (!learningObject.unreleased) { - this.learningPathNavigationService.loadRelativeLearningPathNavigation(this.learningPathId(), learningObject); + await this.learningPathNavigationService.loadRelativeLearningPathNavigation(this.learningPathId(), learningObject); this.onLearningObjectSelected.emit(); } } diff --git a/src/main/webapp/app/course/learning-paths/components/learning-path-nav-overview/learning-path-nav-overview.component.ts b/src/main/webapp/app/course/learning-paths/components/learning-path-nav-overview/learning-path-nav-overview.component.ts index 669bde84d5b3..a63830f98bc7 100644 --- a/src/main/webapp/app/course/learning-paths/components/learning-path-nav-overview/learning-path-nav-overview.component.ts +++ b/src/main/webapp/app/course/learning-paths/components/learning-path-nav-overview/learning-path-nav-overview.component.ts @@ -1,4 +1,4 @@ -import { Component, InputSignal, OutputEmitterRef, Signal, WritableSignal, computed, inject, input, output, signal, viewChild } from '@angular/core'; +import { Component, InputSignal, OutputEmitterRef, Signal, WritableSignal, computed, effect, inject, input, output, signal, viewChild } from '@angular/core'; import { NgbAccordionDirective, NgbAccordionModule, NgbDropdownModule, NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { CommonModule } from '@angular/common'; import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; @@ -10,8 +10,6 @@ import { LearningPathApiService } from 'app/course/learning-paths/services/learn import { CompetencyGraphModalComponent } from 'app/course/learning-paths/components/competency-graph-modal/competency-graph-modal.component'; import { LearningPathNavOverviewLearningObjectsComponent } from 'app/course/learning-paths/components/learning-path-nav-overview-learning-objects/learning-path-nav-overview-learning-objects.component'; import { LearningPathNavigationService } from 'app/course/learning-paths/services/learning-path-navigation.service'; -import { toObservable, toSignal } from '@angular/core/rxjs-interop'; -import { Observable, switchMap } from 'rxjs'; @Component({ selector: 'jhi-learning-path-nav-overview', @@ -34,25 +32,26 @@ export class LearningPathNavOverviewComponent { readonly onLearningObjectSelected: OutputEmitterRef = output(); readonly isLoading: WritableSignal = signal(false); - private readonly competencies$: Observable = toObservable(this.learningPathId).pipe( - switchMap((learningPathId) => this.loadCompetencies(learningPathId)), - ); - readonly competencies: Signal = toSignal(this.competencies$); + readonly competencies = signal([]); // competency id of currently selected learning object readonly currentCompetencyId: Signal = computed(() => this.learningPathNavigationService.currentLearningObject()?.competencyId); // current competency of learning path (not the one of the selected learning object) readonly currentCompetencyOnPath: Signal = computed(() => this.competencies()?.find((competency) => competency.masteryProgress < 1)); - async loadCompetencies(learningPathId: number): Promise { + constructor() { + effect(async () => await this.loadCompetencies(this.learningPathId()), { allowSignalWrites: true }); + } + + async loadCompetencies(learningPathId: number): Promise { try { this.isLoading.set(true); const competencies = await this.learningPathApiService.getLearningPathCompetencies(learningPathId); - this.isLoading.set(false); - return competencies; + this.competencies.set(competencies); } catch (error) { this.alertService.error(error); - return []; + } finally { + this.isLoading.set(false); } } diff --git a/src/main/webapp/app/course/learning-paths/components/learning-path-student-nav/learning-path-student-nav.component.ts b/src/main/webapp/app/course/learning-paths/components/learning-path-student-nav/learning-path-student-nav.component.ts index 906cf5148089..90eb7bb2b43e 100644 --- a/src/main/webapp/app/course/learning-paths/components/learning-path-student-nav/learning-path-student-nav.component.ts +++ b/src/main/webapp/app/course/learning-paths/components/learning-path-student-nav/learning-path-student-nav.component.ts @@ -1,4 +1,4 @@ -import { Component, InputSignal, OnInit, Signal, WritableSignal, computed, inject, input, signal } from '@angular/core'; +import { Component, InputSignal, Signal, WritableSignal, computed, effect, inject, input, signal } from '@angular/core'; import { LearningPathNavigationObjectDTO } from 'app/entities/competency/learning-path.model'; import { CommonModule } from '@angular/common'; import { NgbAccordionModule, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'; @@ -15,7 +15,7 @@ import { LearningPathNavigationService } from 'app/course/learning-paths/service templateUrl: './learning-path-student-nav.component.html', styleUrl: './learning-path-student-nav.component.scss', }) -export class LearningPathNavComponent implements OnInit { +export class LearningPathNavComponent { protected readonly faChevronDown: IconDefinition = faChevronDown; protected readonly faCheckCircle: IconDefinition = faCheckCircle; protected readonly faFlag: IconDefinition = faFlag; @@ -37,12 +37,12 @@ export class LearningPathNavComponent implements OnInit { readonly isDropdownOpen: WritableSignal = signal(false); - ngOnInit(): void { - this.learningPathNavigationService.loadLearningPathNavigation(this.learningPathId()); + constructor() { + effect(async () => await this.learningPathNavigationService.loadLearningPathNavigation(this.learningPathId()), { allowSignalWrites: true }); } - selectLearningObject(selectedLearningObject: LearningPathNavigationObjectDTO): void { - this.learningPathNavigationService.loadRelativeLearningPathNavigation(this.learningPathId(), selectedLearningObject); + async selectLearningObject(selectedLearningObject: LearningPathNavigationObjectDTO): Promise { + await this.learningPathNavigationService.loadRelativeLearningPathNavigation(this.learningPathId(), selectedLearningObject); } completeLearningPath(): void { diff --git a/src/main/webapp/app/course/learning-paths/pages/learning-path-student-page/learning-path-student-page.component.ts b/src/main/webapp/app/course/learning-paths/pages/learning-path-student-page/learning-path-student-page.component.ts index 18d74923ab92..7df486af265c 100644 --- a/src/main/webapp/app/course/learning-paths/pages/learning-path-student-page/learning-path-student-page.component.ts +++ b/src/main/webapp/app/course/learning-paths/pages/learning-path-student-page/learning-path-student-page.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, Signal, inject, signal } from '@angular/core'; +import { Component, effect, inject, signal } from '@angular/core'; import { LearningObjectType } from 'app/entities/competency/learning-path.model'; import { map } from 'rxjs'; import { toSignal } from '@angular/core/rxjs-interop'; @@ -29,7 +29,7 @@ import { ArtemisSharedModule } from 'app/shared/shared.module'; ArtemisSharedModule, ], }) -export class LearningPathStudentPageComponent implements OnInit { +export class LearningPathStudentPageComponent { protected readonly LearningObjectType = LearningObjectType; private readonly learningApiService: LearningPathApiService = inject(LearningPathApiService); @@ -39,12 +39,12 @@ export class LearningPathStudentPageComponent implements OnInit { readonly isLearningPathIdLoading = signal(false); readonly learningPathId = signal(undefined); - readonly courseId: Signal = toSignal(this.activatedRoute.parent!.parent!.params.pipe(map((params) => params.courseId))); + readonly courseId = toSignal(this.activatedRoute.parent!.parent!.params.pipe(map((params) => Number(params.courseId))), { requireSync: true }); readonly currentLearningObject = this.learningPathNavigationService.currentLearningObject; readonly isLearningPathNavigationLoading = this.learningPathNavigationService.isLoading; - ngOnInit(): void { - this.loadLearningPathId(this.courseId()); + constructor() { + effect(async () => await this.loadLearningPathId(this.courseId()), { allowSignalWrites: true }); } private async loadLearningPathId(courseId: number): Promise { diff --git a/src/main/webapp/app/entities/exercise-category.model.ts b/src/main/webapp/app/entities/exercise-category.model.ts index bc134d0b7f29..68f09c914d2d 100644 --- a/src/main/webapp/app/entities/exercise-category.model.ts +++ b/src/main/webapp/app/entities/exercise-category.model.ts @@ -1,6 +1,30 @@ export class ExerciseCategory { public color?: string; + + // TODO should be renamed to "name" -> accessing variable via "category.name" instead of "category.category" - requires database migration (stored as json in database, see the table "exercise_categories") public category?: string; - constructor() {} + constructor(category: string | undefined, color: string | undefined) { + this.color = color; + this.category = category; + } + + equals(otherExerciseCategory: ExerciseCategory): boolean { + return this.color === otherExerciseCategory.color && this.category === otherExerciseCategory.category; + } + + /** + * @param otherExerciseCategory + * @returns the alphanumerical order of the two exercise categories based on their display text + */ + compare(otherExerciseCategory: ExerciseCategory): number { + if (this.category === otherExerciseCategory.category) { + return 0; + } + + const displayText = this.category?.toLowerCase() ?? ''; + const otherCategoryDisplayText = otherExerciseCategory.category?.toLowerCase() ?? ''; + + return displayText < otherCategoryDisplayText ? -1 : 1; + } } diff --git a/src/main/webapp/app/entities/programming-exercise.model.ts b/src/main/webapp/app/entities/programming-exercise.model.ts index f8a37efc1b6e..a2e6a093f9a1 100644 --- a/src/main/webapp/app/entities/programming-exercise.model.ts +++ b/src/main/webapp/app/entities/programming-exercise.model.ts @@ -86,6 +86,7 @@ export enum ProgrammingLanguage { SWIFT = 'SWIFT', OCAML = 'OCAML', EMPTY = 'EMPTY', + RUST = 'RUST', } export enum ProjectType { diff --git a/src/main/webapp/app/exercises/file-upload/manage/file-upload-exercise-update.component.ts b/src/main/webapp/app/exercises/file-upload/manage/file-upload-exercise-update.component.ts index abd489160baa..8cf0fe19dcb1 100644 --- a/src/main/webapp/app/exercises/file-upload/manage/file-upload-exercise-update.component.ts +++ b/src/main/webapp/app/exercises/file-upload/manage/file-upload-exercise-update.component.ts @@ -7,7 +7,6 @@ import { FileUploadExercise } from 'app/entities/file-upload-exercise.model'; import { CourseManagementService } from 'app/course/manage/course-management.service'; import { ExerciseService } from 'app/exercises/shared/exercise/exercise.service'; import { Exercise, ExerciseMode, IncludedInOverallScore, getCourseId, resetForImport } from 'app/entities/exercise.model'; -import { EditorMode } from 'app/shared/markdown-editor/markdown-editor.component'; import { ArtemisNavigationUtilService } from 'app/utils/navigation.utils'; import { ExerciseCategory } from 'app/entities/exercise-category.model'; import { cloneDeep } from 'lodash-es'; @@ -54,7 +53,6 @@ export class FileUploadExerciseUpdateComponent implements AfterViewInit, OnDestr goBackAfterSaving = false; exerciseCategories: ExerciseCategory[]; existingCategories: ExerciseCategory[]; - EditorMode = EditorMode; notificationText?: string; domainActionsProblemStatement = [new MonacoFormulaAction()]; domainActionsExampleSolution = [new MonacoFormulaAction()]; diff --git a/src/main/webapp/app/exercises/modeling/assess/modeling-assessment-editor/modeling-assessment-editor.component.ts b/src/main/webapp/app/exercises/modeling/assess/modeling-assessment-editor/modeling-assessment-editor.component.ts index 53bac3581010..e6610b4edaf0 100644 --- a/src/main/webapp/app/exercises/modeling/assess/modeling-assessment-editor/modeling-assessment-editor.component.ts +++ b/src/main/webapp/app/exercises/modeling/assess/modeling-assessment-editor/modeling-assessment-editor.component.ts @@ -354,8 +354,7 @@ export class ModelingAssessmentEditorComponent implements OnInit { /** * Remove a feedback suggestion because it was accepted or discarded. - * The actual feedback creation when accepting happens in code-editor-ace-component/unreferenced-feedback because they have full control over the suggestion cards. - * @param feedback Feedback suggestion that is removed + * @param feedback Feedback suggestion to remove */ removeSuggestion(feedback: Feedback) { this.feedbackSuggestions = this.feedbackSuggestions.filter((feedbackSuggestion) => feedbackSuggestion !== feedback); diff --git a/src/main/webapp/app/exercises/modeling/manage/modeling-exercise-update.component.ts b/src/main/webapp/app/exercises/modeling/manage/modeling-exercise-update.component.ts index 1da174b17a01..4a4488812450 100644 --- a/src/main/webapp/app/exercises/modeling/manage/modeling-exercise-update.component.ts +++ b/src/main/webapp/app/exercises/modeling/manage/modeling-exercise-update.component.ts @@ -6,7 +6,6 @@ import { ModelingExerciseService } from './modeling-exercise.service'; import { CourseManagementService } from 'app/course/manage/course-management.service'; import { ExerciseService } from 'app/exercises/shared/exercise/exercise.service'; import { ExerciseMode, IncludedInOverallScore, resetForImport } from 'app/entities/exercise.model'; -import { EditorMode } from 'app/shared/markdown-editor/markdown-editor.component'; import { AssessmentType } from 'app/entities/assessment-type.model'; import { switchMap, tap } from 'rxjs/operators'; import { ExerciseGroupService } from 'app/exam/manage/exercise-groups/exercise-group.service'; @@ -55,7 +54,6 @@ export class ModelingExerciseUpdateComponent implements AfterViewInit, OnDestroy readonly IncludedInOverallScore = IncludedInOverallScore; readonly documentationType: DocumentationType = 'Model'; - EditorMode = EditorMode; AssessmentType = AssessmentType; UMLDiagramType = UMLDiagramType; diff --git a/src/main/webapp/app/exercises/programming/hestia/git-diff-report/git-diff-report.module.ts b/src/main/webapp/app/exercises/programming/hestia/git-diff-report/git-diff-report.module.ts index b134a0c17ad2..d72ed6f72de0 100644 --- a/src/main/webapp/app/exercises/programming/hestia/git-diff-report/git-diff-report.module.ts +++ b/src/main/webapp/app/exercises/programming/hestia/git-diff-report/git-diff-report.module.ts @@ -1,6 +1,5 @@ import { NgModule } from '@angular/core'; import { ArtemisSharedModule } from 'app/shared/shared.module'; -import { AceEditorModule } from 'app/shared/markdown-editor/ace-editor/ace-editor.module'; import { GitDiffLineStatComponent } from 'app/exercises/programming/hestia/git-diff-report/git-diff-line-stat.component'; import { GitDiffReportComponent } from './git-diff-report.component'; import { GitDiffFileComponent } from 'app/exercises/programming/hestia/git-diff-report/git-diff-file.component'; @@ -12,7 +11,7 @@ import { GitDiffFilePanelTitleComponent } from 'app/exercises/programming/hestia import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; @NgModule({ - imports: [ArtemisSharedModule, AceEditorModule, NgbAccordionModule, MonacoEditorModule, ArtemisSharedComponentModule], + imports: [ArtemisSharedModule, NgbAccordionModule, MonacoEditorModule, ArtemisSharedComponentModule], declarations: [GitDiffFilePanelComponent, GitDiffFilePanelTitleComponent, GitDiffReportComponent, GitDiffFileComponent, GitDiffReportModalComponent, GitDiffLineStatComponent], exports: [GitDiffReportComponent, GitDiffReportModalComponent, GitDiffLineStatComponent], }) diff --git a/src/main/webapp/app/exercises/programming/manage/instructions-editor/programming-exercise-editable-instruction.component.ts b/src/main/webapp/app/exercises/programming/manage/instructions-editor/programming-exercise-editable-instruction.component.ts index 3dcd5d42efa3..b20fb2d6e149 100644 --- a/src/main/webapp/app/exercises/programming/manage/instructions-editor/programming-exercise-editable-instruction.component.ts +++ b/src/main/webapp/app/exercises/programming/manage/instructions-editor/programming-exercise-editable-instruction.component.ts @@ -2,22 +2,17 @@ import { AfterViewInit, Component, EventEmitter, HostListener, Input, OnChanges, import { AlertService } from 'app/core/util/alert.service'; import { Observable, Subject, Subscription, of, throwError } from 'rxjs'; import { catchError, map as rxMap, switchMap, tap } from 'rxjs/operators'; -import { TaskCommand } from 'app/shared/markdown-editor/domainCommands/programming-exercise/task.command'; -import { TestCaseCommand } from 'app/shared/markdown-editor/domainCommands/programming-exercise/testCase.command'; import { ProgrammingExerciseTestCase } from 'app/entities/programming-exercise-test-case.model'; import { ProblemStatementAnalysis } from 'app/exercises/programming/manage/instructions-editor/analysis/programming-exercise-instruction-analysis.model'; import { Participation } from 'app/entities/participation/participation.model'; import { ProgrammingExerciseService } from 'app/exercises/programming/manage/services/programming-exercise.service'; import { ProgrammingExercise } from 'app/entities/programming-exercise.model'; import { hasExerciseChanged } from 'app/exercises/shared/exercise/exercise.utils'; -import { MarkdownEditorHeight } from 'app/shared/markdown-editor/markdown-editor.component'; import { ProgrammingExerciseParticipationService } from 'app/exercises/programming/manage/services/programming-exercise-participation.service'; -import { DomainCommand } from 'app/shared/markdown-editor/domainCommands/domainCommand'; import { ProgrammingExerciseGradingService } from 'app/exercises/programming/manage/services/programming-exercise-grading.service'; -import { KatexCommand } from 'app/shared/markdown-editor/commands/katex.command'; import { Result } from 'app/entities/result.model'; import { faCheckCircle, faCircleNotch, faExclamationTriangle, faGripLines, faSave } from '@fortawesome/free-solid-svg-icons'; -import { MarkdownEditorMonacoComponent } from 'app/shared/markdown-editor/monaco/markdown-editor-monaco.component'; +import { MarkdownEditorHeight, MarkdownEditorMonacoComponent } from 'app/shared/markdown-editor/monaco/markdown-editor-monaco.component'; import { Annotation } from 'app/exercises/programming/shared/code-editor/monaco/code-editor-monaco.component'; import { MonacoFormulaAction } from 'app/shared/monaco-editor/model/actions/monaco-formula.action'; import { MonacoTaskAction } from 'app/shared/monaco-editor/model/actions/monaco-task.action'; @@ -36,11 +31,7 @@ export class ProgrammingExerciseEditableInstructionComponent implements AfterVie exerciseTestCases: string[] = []; - taskCommand = new TaskCommand(); - taskRegex = this.taskCommand.getTagRegex('g'); - testCaseCommand = new TestCaseCommand(); - katexCommand = new KatexCommand(); - domainCommands: DomainCommand[] = [this.katexCommand, this.taskCommand, this.testCaseCommand]; + taskRegex = MonacoTaskAction.GLOBAL_TASK_REGEX; testCaseAction = new MonacoTestCaseAction(); domainActions: MonacoEditorDomainAction[] = [new MonacoFormulaAction(), new MonacoTaskAction(), this.testCaseAction]; @@ -85,7 +76,6 @@ export class ProgrammingExerciseEditableInstructionComponent implements AfterVie this.unsavedChanges = true; } this.programmingExercise = exercise; - this.domainCommands = [this.katexCommand, this.taskCommand, this.testCaseCommand]; this.exerciseChange.emit(this.programmingExercise); } @@ -215,7 +205,6 @@ export class ProgrammingExerciseEditableInstructionComponent implements AfterVie tap((testCaseNames: string[]) => { this.exerciseTestCases = testCaseNames; const cases = this.exerciseTestCases.map((value) => ({ value, id: value })); - this.testCaseCommand.setValues(cases); this.testCaseAction.setValues(cases); }), catchError(() => of()), diff --git a/src/main/webapp/app/exercises/programming/manage/programming-exercise-management.module.ts b/src/main/webapp/app/exercises/programming/manage/programming-exercise-management.module.ts index 2ccadafe3cc1..1cb726111a17 100644 --- a/src/main/webapp/app/exercises/programming/manage/programming-exercise-management.module.ts +++ b/src/main/webapp/app/exercises/programming/manage/programming-exercise-management.module.ts @@ -24,7 +24,6 @@ import { ProgrammingExerciseExampleSolutionRepoDownloadComponent } from 'app/exe import { TestwiseCoverageReportModule } from 'app/exercises/programming/hestia/testwise-coverage-report/testwise-coverage-report.module'; import { ArtemisCodeHintGenerationOverviewModule } from 'app/exercises/programming/hestia/generation-overview/code-hint-generation-overview/code-hint-generation-overview.module'; import { BuildPlanEditorComponent } from 'app/exercises/programming/manage/build-plan-editor.component'; -import { AceEditorModule } from 'app/shared/markdown-editor/ace-editor/ace-editor.module'; import { ArtemisCodeEditorModule } from 'app/exercises/programming/shared/code-editor/code-editor.module'; import { DetailModule } from 'app/detail-overview-list/detail.module'; import { IrisModule } from 'app/iris/iris.module'; @@ -53,7 +52,6 @@ import { MonacoEditorModule } from 'app/shared/monaco-editor/monaco-editor.modul GitDiffReportModule, TestwiseCoverageReportModule, ArtemisCodeHintGenerationOverviewModule, - AceEditorModule, ArtemisCodeEditorModule, ArtemisExerciseModule, DetailModule, diff --git a/src/main/webapp/app/exercises/programming/manage/update/programming-exercise-update.component.ts b/src/main/webapp/app/exercises/programming/manage/update/programming-exercise-update.component.ts index 755a6241cfca..e48803fc9c81 100644 --- a/src/main/webapp/app/exercises/programming/manage/update/programming-exercise-update.component.ts +++ b/src/main/webapp/app/exercises/programming/manage/update/programming-exercise-update.component.ts @@ -13,7 +13,6 @@ import { FeatureToggle } from 'app/shared/feature-toggle/feature-toggle.service' import { ExerciseService } from 'app/exercises/shared/exercise/exercise.service'; import { AssessmentType } from 'app/entities/assessment-type.model'; import { Exercise, IncludedInOverallScore, ValidationReason } from 'app/entities/exercise.model'; -import { EditorMode } from 'app/shared/markdown-editor/markdown-editor.component'; import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; import { ExerciseGroupService } from 'app/exam/manage/exercise-groups/exercise-group.service'; import { ProgrammingLanguageFeatureService } from 'app/exercises/programming/shared/service/programming-language-feature/programming-language-feature.service'; @@ -85,7 +84,6 @@ export class ProgrammingExerciseUpdateComponent implements AfterViewInit, OnDest notificationText?: string; courseId: number; - EditorMode = EditorMode; AssessmentType = AssessmentType; rerenderSubject = new Subject(); // This is used to revert the select if the user cancels to override the new selected programming language. diff --git a/src/main/webapp/app/exercises/programming/manage/update/programming-exercise-update.module.ts b/src/main/webapp/app/exercises/programming/manage/update/programming-exercise-update.module.ts index 29c6704e6d08..8c5fa972cd92 100644 --- a/src/main/webapp/app/exercises/programming/manage/update/programming-exercise-update.module.ts +++ b/src/main/webapp/app/exercises/programming/manage/update/programming-exercise-update.module.ts @@ -26,7 +26,6 @@ import { ExerciseTitleChannelNameModule } from 'app/exercises/shared/exercise-ti import { ExerciseUpdateNotificationModule } from 'app/exercises/shared/exercise-update-notification/exercise-update-notification.module'; import { ExerciseUpdatePlagiarismModule } from 'app/exercises/shared/plagiarism/exercise-update-plagiarism/exercise-update-plagiarism.module'; import { ProgrammingExerciseCustomAeolusBuildPlanComponent } from 'app/exercises/programming/manage/update/update-components/custom-build-plans/programming-exercise-custom-aeolus-build-plan.component'; -import { AceEditorModule } from 'app/shared/markdown-editor/ace-editor/ace-editor.module'; import { ProgrammingExerciseCustomBuildPlanComponent } from 'app/exercises/programming/manage/update/update-components/custom-build-plans/programming-exercise-custom-build-plan.component'; import { ProgrammingExerciseDockerImageComponent } from 'app/exercises/programming/manage/update/update-components/custom-build-plans/programming-exercise-docker-image/programming-exercise-docker-image.component'; import { FormsModule } from 'app/forms/forms.module'; @@ -54,7 +53,6 @@ import { MonacoEditorModule } from 'app/shared/monaco-editor/monaco-editor.modul ExerciseTitleChannelNameModule, ExerciseUpdateNotificationModule, ExerciseUpdatePlagiarismModule, - AceEditorModule, FormsModule, ProgrammingExerciseBuildPlanCheckoutDirectoriesComponent, ProgrammingExerciseRepositoryAndBuildPlanDetailsComponent, diff --git a/src/main/webapp/app/exercises/programming/manage/update/update-components/custom-build-plans/programming-exercise-custom-build-plan.component.ts b/src/main/webapp/app/exercises/programming/manage/update/update-components/custom-build-plans/programming-exercise-custom-build-plan.component.ts index 437d2f56d57e..1a314af06cfd 100644 --- a/src/main/webapp/app/exercises/programming/manage/update/update-components/custom-build-plans/programming-exercise-custom-build-plan.component.ts +++ b/src/main/webapp/app/exercises/programming/manage/update/update-components/custom-build-plans/programming-exercise-custom-build-plan.component.ts @@ -132,7 +132,7 @@ export class ProgrammingExerciseCustomBuildPlanComponent implements OnChanges { } /** - * Sets up an ace editor for the build plan script + * Sets up the Monaco editor for the build plan script */ setupEditor(): void { if (!this._editor) { diff --git a/src/main/webapp/app/exercises/programming/manage/update/update-components/programming-exercise-problem.component.ts b/src/main/webapp/app/exercises/programming/manage/update/update-components/programming-exercise-problem.component.ts index f6625d0f56fe..74eba35715a5 100644 --- a/src/main/webapp/app/exercises/programming/manage/update/update-components/programming-exercise-problem.component.ts +++ b/src/main/webapp/app/exercises/programming/manage/update/update-components/programming-exercise-problem.component.ts @@ -3,7 +3,8 @@ import { ProgrammingExercise, ProgrammingLanguage, ProjectType } from 'app/entit import { AssessmentType } from 'app/entities/assessment-type.model'; import { faQuestionCircle } from '@fortawesome/free-solid-svg-icons'; import { ProgrammingExerciseCreationConfig } from 'app/exercises/programming/manage/update/programming-exercise-creation-config'; -import { MarkdownEditorHeight } from 'app/shared/markdown-editor/markdown-editor.component'; + +import { MarkdownEditorHeight } from 'app/shared/markdown-editor/monaco/markdown-editor-monaco.component'; @Component({ selector: 'jhi-programming-exercise-problem', diff --git a/src/main/webapp/app/exercises/programming/shared/code-editor/code-editor.module.ts b/src/main/webapp/app/exercises/programming/shared/code-editor/code-editor.module.ts index d403afbfa572..7b23b4959793 100644 --- a/src/main/webapp/app/exercises/programming/shared/code-editor/code-editor.module.ts +++ b/src/main/webapp/app/exercises/programming/shared/code-editor/code-editor.module.ts @@ -1,5 +1,4 @@ import { NgModule } from '@angular/core'; -import { AceEditorModule } from 'app/shared/markdown-editor/ace-editor/ace-editor.module'; import { CodeEditorBuildOutputComponent } from 'app/exercises/programming/shared/code-editor/build-output/code-editor-build-output.component'; import { CodeEditorGridComponent } from 'app/exercises/programming/shared/code-editor/layout/code-editor-grid.component'; import { CodeEditorActionsComponent } from 'app/exercises/programming/shared/code-editor/actions/code-editor-actions.component'; @@ -28,7 +27,6 @@ import { ArtemisSharedComponentModule } from 'app/shared/components/shared-compo @NgModule({ imports: [ - AceEditorModule, NgbModule, ArtemisSharedModule, FeatureToggleModule, diff --git a/src/main/webapp/app/exercises/programming/shared/code-editor/header/code-editor-header.component.ts b/src/main/webapp/app/exercises/programming/shared/code-editor/header/code-editor-header.component.ts index 2a509207248b..e340a1dcb4f2 100644 --- a/src/main/webapp/app/exercises/programming/shared/code-editor/header/code-editor-header.component.ts +++ b/src/main/webapp/app/exercises/programming/shared/code-editor/header/code-editor-header.component.ts @@ -1,7 +1,7 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'; -import { MAX_TAB_SIZE } from 'app/shared/markdown-editor/ace-editor/ace-editor.component'; import { faFileAlt } from '@fortawesome/free-regular-svg-icons'; import { faCircleNotch, faGear } from '@fortawesome/free-solid-svg-icons'; +import { MAX_TAB_SIZE } from 'app/shared/monaco-editor/monaco-editor.component'; @Component({ selector: 'jhi-code-editor-header', diff --git a/src/main/webapp/app/exercises/programming/shared/code-editor/monaco/code-editor-monaco.component.ts b/src/main/webapp/app/exercises/programming/shared/code-editor/monaco/code-editor-monaco.component.ts index 5ea72401832e..b16d76dc66a4 100644 --- a/src/main/webapp/app/exercises/programming/shared/code-editor/monaco/code-editor-monaco.component.ts +++ b/src/main/webapp/app/exercises/programming/shared/code-editor/monaco/code-editor-monaco.component.ts @@ -3,7 +3,7 @@ import { RepositoryFileService } from 'app/exercises/shared/result/repository.se import { CodeEditorRepositoryFileService, ConnectionError } from 'app/exercises/programming/shared/code-editor/service/code-editor-repository.service'; import { CodeEditorFileService } from 'app/exercises/programming/shared/code-editor/service/code-editor-file.service'; import { LocalStorageService } from 'ngx-webstorage'; -import { MonacoEditorComponent } from 'app/shared/monaco-editor/monaco-editor.component'; +import { EditorPosition, MonacoEditorComponent } from 'app/shared/monaco-editor/monaco-editor.component'; import { firstValueFrom, timeout } from 'rxjs'; import { FEEDBACK_SUGGESTION_ACCEPTED_IDENTIFIER, FEEDBACK_SUGGESTION_IDENTIFIER, Feedback } from 'app/entities/feedback.model'; import { Course } from 'app/entities/course.model'; @@ -22,7 +22,7 @@ import { CodeEditorTutorAssessmentInlineFeedbackSuggestionComponent } from 'app/ import { MonacoEditorLineHighlight } from 'app/shared/monaco-editor/model/monaco-editor-line-highlight.model'; import { FileTypeService } from 'app/exercises/programming/shared/service/file-type.service'; -type FileSession = { [fileName: string]: { code: string; cursor: { column: number; row: number }; loadingError: boolean } }; +type FileSession = { [fileName: string]: { code: string; cursor: EditorPosition; loadingError: boolean } }; export type Annotation = { fileName: string; row: number; column: number; text: string; type: string; timestamp: number; hash?: string }; @Component({ selector: 'jhi-code-editor-monaco', @@ -159,7 +159,7 @@ export class CodeEditorMonacoComponent implements OnChanges { this.onError.emit('loadingFailed'); } } - this.fileSession[fileName] = { code: fileContent, loadingError, cursor: { column: 0, row: 0 } }; + this.fileSession[fileName] = { code: fileContent, loadingError, cursor: { column: 0, lineNumber: 0 } }; } const code = this.fileSession[fileName].code; @@ -329,7 +329,7 @@ export class CodeEditorMonacoComponent implements OnChanges { } // In the future, there may be more than one feedback node per line. const feedbackNode = this.getInlineFeedbackNodeOrElseThrow(line); - // The lines are 0-based for Ace, but 1-based for Monaco -> increase by 1 to ensure it works in both editors. + // Feedback is stored with 0-based lines, but the lines of the Monaco editor used in Artemis are 1-based. We add 1 to correct this this.editor.addLineWidget(line + 1, 'feedback-' + feedback.id, feedbackNode); } @@ -365,7 +365,7 @@ export class CodeEditorMonacoComponent implements OnChanges { this.fileSession = this.fileService.updateFileReferences(this.fileSession, fileChange); this.storeAnnotations([fileChange.fileName]); } else if (fileChange instanceof CreateFileChange && fileChange.fileType === FileType.FILE) { - this.fileSession = { ...this.fileSession, [fileChange.fileName]: { code: '', cursor: { row: 0, column: 0 }, loadingError: false } }; + this.fileSession = { ...this.fileSession, [fileChange.fileName]: { code: '', cursor: { lineNumber: 0, column: 0 }, loadingError: false } }; } this.setBuildAnnotations(this.annotationsArray); } diff --git a/src/main/webapp/app/exercises/quiz/manage/drag-and-drop-question/drag-and-drop-question-edit.component.ts b/src/main/webapp/app/exercises/quiz/manage/drag-and-drop-question/drag-and-drop-question-edit.component.ts index 784a16bcffcb..49e6017a3f2f 100644 --- a/src/main/webapp/app/exercises/quiz/manage/drag-and-drop-question/drag-and-drop-question-edit.component.ts +++ b/src/main/webapp/app/exercises/quiz/manage/drag-and-drop-question/drag-and-drop-question-edit.component.ts @@ -77,9 +77,7 @@ export class DragAndDropQuestionEditComponent implements OnInit, OnChanges, Afte @Output() addNewFile = new EventEmitter<{ fileName: string; path?: string; file: File }>(); @Output() removeFile = new EventEmitter(); - /** Ace Editor configuration constants **/ questionEditorText = ''; - backupQuestion: DragAndDropQuestion; filePreviewPaths: Map = new Map(); dropAllowed = false; diff --git a/src/main/webapp/app/exercises/quiz/manage/multiple-choice-question/multiple-choice-question-edit.component.ts b/src/main/webapp/app/exercises/quiz/manage/multiple-choice-question/multiple-choice-question-edit.component.ts index 014af02dd3b4..be36aaedd0e7 100644 --- a/src/main/webapp/app/exercises/quiz/manage/multiple-choice-question/multiple-choice-question-edit.component.ts +++ b/src/main/webapp/app/exercises/quiz/manage/multiple-choice-question/multiple-choice-question-edit.component.ts @@ -39,10 +39,7 @@ export class MultipleChoiceQuestionEditComponent implements OnInit, QuizQuestion @Output() questionDeleted = new EventEmitter(); - /** Ace Editor configuration constants **/ questionEditorText = ''; - - /** Status boolean for collapse status **/ isQuestionCollapsed: boolean; /** Set default preview of the markdown editor as preview for the multiple choice question **/ diff --git a/src/main/webapp/app/exercises/quiz/manage/quiz-management.module.ts b/src/main/webapp/app/exercises/quiz/manage/quiz-management.module.ts index 2140ad695252..829eae9fbb76 100644 --- a/src/main/webapp/app/exercises/quiz/manage/quiz-management.module.ts +++ b/src/main/webapp/app/exercises/quiz/manage/quiz-management.module.ts @@ -2,7 +2,6 @@ import { NgModule } from '@angular/core'; import { MultipleChoiceQuestionEditComponent } from './multiple-choice-question/multiple-choice-question-edit.component'; import { DragAndDropQuestionEditComponent } from './drag-and-drop-question/drag-and-drop-question-edit.component'; import { ShortAnswerQuestionEditComponent } from './short-answer-question/short-answer-question-edit.component'; -import { AceEditorModule } from 'app/shared/markdown-editor/ace-editor/ace-editor.module'; import { ArtemisMarkdownEditorModule } from 'app/shared/markdown-editor/markdown-editor.module'; import { DragDropModule } from '@angular/cdk/drag-drop'; import { QuizScoringInfoModalComponent } from './quiz-scoring-info-modal/quiz-scoring-info-modal.component'; @@ -50,7 +49,6 @@ const ENTITY_STATES = [...quizManagementRoute]; ArtemisSharedModule, RouterModule.forChild(ENTITY_STATES), DragDropModule, - AceEditorModule, FormDateTimePickerModule, ArtemisQuizQuestionTypesModule, ArtemisMarkdownEditorModule, diff --git a/src/main/webapp/app/exercises/quiz/manage/re-evaluate/multiple-choice-question/re-evaluate-multiple-choice-question.component.ts b/src/main/webapp/app/exercises/quiz/manage/re-evaluate/multiple-choice-question/re-evaluate-multiple-choice-question.component.ts index acf8aea5f8d2..142869ee1ce8 100644 --- a/src/main/webapp/app/exercises/quiz/manage/re-evaluate/multiple-choice-question/re-evaluate-multiple-choice-question.component.ts +++ b/src/main/webapp/app/exercises/quiz/manage/re-evaluate/multiple-choice-question/re-evaluate-multiple-choice-question.component.ts @@ -1,14 +1,13 @@ import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; import { AnswerOption } from 'app/entities/quiz/answer-option.model'; import { MultipleChoiceQuestion } from 'app/entities/quiz/multiple-choice-question.model'; -import { CorrectOptionCommand } from 'app/shared/markdown-editor/domainCommands/correctOptionCommand'; -import { IncorrectOptionCommand } from 'app/shared/markdown-editor/domainCommands/incorrectOptionCommand'; import { escapeStringForUseInRegex } from 'app/shared/util/global.utils'; import { cloneDeep } from 'lodash-es'; -import { EditorMode } from 'app/shared/markdown-editor/markdown-editor.component'; import { generateExerciseHintExplanation, parseExerciseHintExplanation } from 'app/shared/util/markdown.util'; import { faAngleDown, faAngleRight, faArrowsAltV, faChevronDown, faChevronUp, faTrash, faUndo } from '@fortawesome/free-solid-svg-icons'; import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'; +import { MonacoCorrectMultipleChoiceAnswerAction } from 'app/shared/monaco-editor/model/actions/quiz/monaco-correct-multiple-choice-answer.action'; +import { MonacoWrongMultipleChoiceAnswerAction } from 'app/shared/monaco-editor/model/actions/quiz/monaco-wrong-multiple-choice-answer.action'; @Component({ selector: 'jhi-re-evaluate-multiple-choice-question', @@ -24,7 +23,6 @@ export class ReEvaluateMultipleChoiceQuestionComponent implements OnInit { @Output() questionMoveUp = new EventEmitter(); @Output() questionMoveDown = new EventEmitter(); - editorMode = EditorMode.NONE; markdownMap: Map; questionText: string; @@ -47,7 +45,9 @@ export class ReEvaluateMultipleChoiceQuestionComponent implements OnInit { for (const answer of this.question.answerOptions!) { this.markdownMap.set( answer.id!, - (answer.isCorrect ? CorrectOptionCommand.IDENTIFIER : IncorrectOptionCommand.IDENTIFIER) + ' ' + generateExerciseHintExplanation(answer), + (answer.isCorrect ? MonacoCorrectMultipleChoiceAnswerAction.IDENTIFIER : MonacoWrongMultipleChoiceAnswerAction.IDENTIFIER) + + ' ' + + generateExerciseHintExplanation(answer), ); } this.questionText = this.getQuestionText(this.question); @@ -104,9 +104,9 @@ export class ReEvaluateMultipleChoiceQuestionComponent implements OnInit { const startOfThisPart = text.indexOf(answerOptionText); const box = text.substring(0, startOfThisPart); // Check if box says this answer option is correct or not - if (box === CorrectOptionCommand.IDENTIFIER) { + if (box === MonacoCorrectMultipleChoiceAnswerAction.IDENTIFIER) { answer.isCorrect = true; - } else if (box === IncorrectOptionCommand.IDENTIFIER) { + } else if (box === MonacoWrongMultipleChoiceAnswerAction.IDENTIFIER) { answer.isCorrect = false; } else { answer.isCorrect = undefined; @@ -119,7 +119,10 @@ export class ReEvaluateMultipleChoiceQuestionComponent implements OnInit { * @param text */ private static splitByCorrectIncorrectTag(text: string): string[] { - const stringForSplit = escapeStringForUseInRegex(`${CorrectOptionCommand.IDENTIFIER}`) + '|' + escapeStringForUseInRegex(`${IncorrectOptionCommand.IDENTIFIER}`); + const stringForSplit = + escapeStringForUseInRegex(`${MonacoCorrectMultipleChoiceAnswerAction.IDENTIFIER}`) + + '|' + + escapeStringForUseInRegex(`${MonacoWrongMultipleChoiceAnswerAction.IDENTIFIER}`); const splitRegExp = new RegExp(stringForSplit, 'g'); return text.split(splitRegExp); } diff --git a/src/main/webapp/app/exercises/quiz/manage/short-answer-question/short-answer-question-edit.component.ts b/src/main/webapp/app/exercises/quiz/manage/short-answer-question/short-answer-question-edit.component.ts index 538966d0c15f..86264112bb2d 100644 --- a/src/main/webapp/app/exercises/quiz/manage/short-answer-question/short-answer-question-edit.component.ts +++ b/src/main/webapp/app/exercises/quiz/manage/short-answer-question/short-answer-question-edit.component.ts @@ -25,8 +25,7 @@ import { markdownForHtml } from 'app/shared/util/markdown.conversion.util'; import { generateExerciseHintExplanation, parseExerciseHintExplanation } from 'app/shared/util/markdown.util'; import { faAngleDown, faAngleRight, faBan, faBars, faChevronDown, faChevronUp, faTrash, faUndo, faUnlink } from '@fortawesome/free-solid-svg-icons'; import { MAX_QUIZ_QUESTION_POINTS, MAX_QUIZ_SHORT_ANSWER_TEXT_LENGTH } from 'app/shared/constants/input.constants'; -import { MarkdownEditorMonacoComponent } from 'app/shared/markdown-editor/monaco/markdown-editor-monaco.component'; -import { MarkdownEditorHeight } from 'app/shared/markdown-editor/markdown-editor.component'; +import { MarkdownEditorHeight, MarkdownEditorMonacoComponent } from 'app/shared/markdown-editor/monaco/markdown-editor-monaco.component'; import { MonacoBoldAction } from 'app/shared/monaco-editor/model/actions/monaco-bold.action'; import { MonacoItalicAction } from 'app/shared/monaco-editor/model/actions/monaco-italic.action'; import { MonacoUnderlineAction } from 'app/shared/monaco-editor/model/actions/monaco-underline.action'; diff --git a/src/main/webapp/app/exercises/shared/exercise-hint/manage/exercise-hint-update.component.ts b/src/main/webapp/app/exercises/shared/exercise-hint/manage/exercise-hint-update.component.ts index c4c7b78f0496..b734c1c628df 100644 --- a/src/main/webapp/app/exercises/shared/exercise-hint/manage/exercise-hint-update.component.ts +++ b/src/main/webapp/app/exercises/shared/exercise-hint/manage/exercise-hint-update.component.ts @@ -4,7 +4,6 @@ import { ActivatedRoute } from '@angular/router'; import { Observable, Subscription, filter, switchMap } from 'rxjs'; import { AlertService } from 'app/core/util/alert.service'; import { ExerciseHintService } from '../shared/exercise-hint.service'; -import { MarkdownEditorHeight } from 'app/shared/markdown-editor/markdown-editor.component'; import { ArtemisNavigationUtilService } from 'app/utils/navigation.utils'; import { faBan, faCircleNotch, faSave } from '@fortawesome/free-solid-svg-icons'; import { ExerciseHint, HintType } from 'app/entities/hestia/exercise-hint.model'; @@ -23,6 +22,7 @@ import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; import { ButtonType } from 'app/shared/components/button.component'; import { PROFILE_IRIS } from 'app/app.constants'; import { MonacoFormulaAction } from 'app/shared/monaco-editor/model/actions/monaco-formula.action'; +import { MarkdownEditorHeight } from 'app/shared/markdown-editor/monaco/markdown-editor-monaco.component'; const DEFAULT_DISPLAY_THRESHOLD = 3; diff --git a/src/main/webapp/app/exercises/shared/exercise/exercise.service.ts b/src/main/webapp/app/exercises/shared/exercise/exercise.service.ts index dffb26604cc7..50c2227751e3 100644 --- a/src/main/webapp/app/exercises/shared/exercise/exercise.service.ts +++ b/src/main/webapp/app/exercises/shared/exercise/exercise.service.ts @@ -347,7 +347,7 @@ export class ExerciseService { * @param exercise the exercise */ static stringifyExerciseCategories(exercise: Exercise) { - return exercise.categories?.map((category) => JSON.stringify(category) as ExerciseCategory); + return exercise.categories?.map((category) => JSON.stringify(category) as unknown as ExerciseCategory); } /** @@ -362,12 +362,15 @@ export class ExerciseService { } /** - * Parses the exercise categories JSON string into ExerciseCategory objects. + * Parses the exercise categories JSON string into {@link ExerciseCategory} objects. * @param exercise - the exercise */ static parseExerciseCategories(exercise?: Exercise) { if (exercise?.categories) { - exercise.categories = exercise.categories.map((category) => JSON.parse(category as string) as ExerciseCategory); + exercise.categories = exercise.categories.map((category) => { + const categoryObj = JSON.parse(category as unknown as string); + return new ExerciseCategory(categoryObj.category, categoryObj.color); + }); } } diff --git a/src/main/webapp/app/exercises/shared/participation/participation.utils.ts b/src/main/webapp/app/exercises/shared/participation/participation.utils.ts index 7c7a99d3830c..5fc349f22b27 100644 --- a/src/main/webapp/app/exercises/shared/participation/participation.utils.ts +++ b/src/main/webapp/app/exercises/shared/participation/participation.utils.ts @@ -102,7 +102,11 @@ export const isParticipationInDueTime = (participation: Participation, exercise: * @param participation * @param showUngradedResults */ -export function getLatestResultOfStudentParticipation(participation: StudentParticipation, showUngradedResults: boolean): Result | undefined { +export function getLatestResultOfStudentParticipation(participation: StudentParticipation | undefined, showUngradedResults: boolean): Result | undefined { + if (!participation) { + return undefined; + } + // Sort participation results by completionDate desc. if (participation.results) { participation.results = _orderBy(participation.results, 'completionDate', 'desc'); diff --git a/src/main/webapp/app/exercises/shared/structured-grading-criterion/grading-instructions-details/grading-instructions-details.component.ts b/src/main/webapp/app/exercises/shared/structured-grading-criterion/grading-instructions-details/grading-instructions-details.component.ts index 64448a6f4569..7f6fa6f89ccd 100644 --- a/src/main/webapp/app/exercises/shared/structured-grading-criterion/grading-instructions-details/grading-instructions-details.component.ts +++ b/src/main/webapp/app/exercises/shared/structured-grading-criterion/grading-instructions-details/grading-instructions-details.component.ts @@ -1,6 +1,5 @@ import { AfterContentInit, ChangeDetectorRef, Component, Input, OnInit, QueryList, ViewChild, ViewChildren } from '@angular/core'; import { GradingCriterion } from 'app/exercises/shared/structured-grading-criterion/grading-criterion.model'; -import { MarkdownEditorHeight } from 'app/shared/markdown-editor/markdown-editor.component'; import { GradingInstruction } from 'app/exercises/shared/structured-grading-criterion/grading-instruction.model'; import { Exercise } from 'app/entities/exercise.model'; import { cloneDeep } from 'lodash-es'; @@ -11,7 +10,7 @@ import { MonacoGradingScaleAction } from 'app/shared/monaco-editor/model/actions import { MonacoGradingDescriptionAction } from 'app/shared/monaco-editor/model/actions/grading-criteria/monaco-grading-description.action'; import { MonacoGradingFeedbackAction } from 'app/shared/monaco-editor/model/actions/grading-criteria/monaco-grading-feedback.action'; import { MonacoGradingUsageCountAction } from 'app/shared/monaco-editor/model/actions/grading-criteria/monaco-grading-usage-count.action'; -import { MarkdownEditorMonacoComponent, TextWithDomainAction } from 'app/shared/markdown-editor/monaco/markdown-editor-monaco.component'; +import { MarkdownEditorHeight, MarkdownEditorMonacoComponent, TextWithDomainAction } from 'app/shared/markdown-editor/monaco/markdown-editor-monaco.component'; import { MonacoGradingCriterionAction } from 'app/shared/monaco-editor/model/actions/grading-criteria/monaco-grading-criterion.action'; import { MonacoGradingInstructionAction } from 'app/shared/monaco-editor/model/actions/grading-criteria/monaco-grading-instruction.action'; @@ -21,8 +20,6 @@ import { MonacoGradingInstructionAction } from 'app/shared/monaco-editor/model/a styleUrls: ['./grading-instructions-details.component.scss'], }) export class GradingInstructionsDetailsComponent implements OnInit, AfterContentInit { - /** Ace Editor configuration constants **/ - markdownEditorText = ''; @ViewChildren('markdownEditors') private markdownEditors: QueryList; @ViewChild('markdownEditor', { static: false }) @@ -33,6 +30,8 @@ export class GradingInstructionsDetailsComponent implements OnInit, AfterContent private criteria: GradingCriterion[]; backupExercise: Exercise; + markdownEditorText = ''; + showEditMode: boolean; creditsAction = new MonacoGradingCreditsAction(); gradingScaleAction = new MonacoGradingScaleAction(); @@ -52,8 +51,6 @@ export class GradingInstructionsDetailsComponent implements OnInit, AfterContent this.gradingCriterionAction, ]; - showEditMode: boolean; - domainActionsForGradingInstructionParsing: MonacoEditorDomainAction[] = [ this.creditsAction, this.gradingScaleAction, diff --git a/src/main/webapp/app/guided-tour/guided-tour.component.html b/src/main/webapp/app/guided-tour/guided-tour.component.html index 573b73a72161..b970883717e9 100644 --- a/src/main/webapp/app/guided-tour/guided-tour.component.html +++ b/src/main/webapp/app/guided-tour/guided-tour.component.html @@ -92,7 +92,7 @@
} - @if (currentTourStep.userInteractionEvent === UserInteractionEvent.ACE_EDITOR) { + @if (currentTourStep.userInteractionEvent === UserInteractionEvent.MONACO_EDITOR) { } @if (currentTourStep.userInteractionEvent === UserInteractionEvent.WAIT_FOR_SELECTOR) { @@ -110,7 +110,7 @@
} - @if (currentTourStep.userInteractionEvent === UserInteractionEvent.ACE_EDITOR) { + @if (currentTourStep.userInteractionEvent === UserInteractionEvent.MONACO_EDITOR) { } @if (currentTourStep.userInteractionEvent === UserInteractionEvent.WAIT_FOR_SELECTOR) { diff --git a/src/main/webapp/app/guided-tour/guided-tour.constants.ts b/src/main/webapp/app/guided-tour/guided-tour.constants.ts index 58605d5f213c..5c2e08c5a322 100644 --- a/src/main/webapp/app/guided-tour/guided-tour.constants.ts +++ b/src/main/webapp/app/guided-tour/guided-tour.constants.ts @@ -34,7 +34,7 @@ export enum UserInteractionEvent { /** Listen for click events */ CLICK = 'click', /** Observe added or removed lines in the code editor */ - ACE_EDITOR = 'ace-editor', + MONACO_EDITOR = 'monaco-editor', /** Wait for the highlight element of the next tour step to be shown in the DOM */ WAIT_FOR_SELECTOR = 'wait-for-selector', /** Observe and assess the given modeling task in the Apollon editor */ diff --git a/src/main/webapp/app/guided-tour/guided-tour.service.ts b/src/main/webapp/app/guided-tour/guided-tour.service.ts index 71865327a9cc..dcf2872f1f98 100644 --- a/src/main/webapp/app/guided-tour/guided-tour.service.ts +++ b/src/main/webapp/app/guided-tour/guided-tour.service.ts @@ -641,9 +641,9 @@ export class GuidedTourService { }, false, ); - } else if (userInteraction === UserInteractionEvent.ACE_EDITOR) { - /** We observe any added or removed lines in the .ace_text-layer node and trigger enableNextStepClick() */ - targetNode = document.querySelector('.ace_text-layer') as HTMLElement; + } else if (userInteraction === UserInteractionEvent.MONACO_EDITOR) { + /** We observe any added or removed lines in the Monaco editor's .view-lines node and trigger enableNextStepClick() */ + targetNode = document.querySelector('.monaco-editor .view-lines') as HTMLElement; this.observeMutations(targetNode, options) .pipe( filter( diff --git a/src/main/webapp/app/guided-tour/tours/code-editor-tour.ts b/src/main/webapp/app/guided-tour/tours/code-editor-tour.ts index a213c1f0dcb0..19c2067ff031 100644 --- a/src/main/webapp/app/guided-tour/tours/code-editor-tour.ts +++ b/src/main/webapp/app/guided-tour/tours/code-editor-tour.ts @@ -24,11 +24,11 @@ export const codeEditorTour: GuidedTour = { }), new UserInterActionTourStep({ highlightSelector: '.guided-tour-editor', - headlineTranslateKey: 'tour.programmingExercise.codeEditor.aceEditor.headline', - contentTranslateKey: 'tour.programmingExercise.codeEditor.aceEditor.content', + headlineTranslateKey: 'tour.programmingExercise.codeEditor.monacoEditor.headline', + contentTranslateKey: 'tour.programmingExercise.codeEditor.monacoEditor.content', highlightPadding: 5, orientation: Orientation.RIGHT, - userInteractionEvent: UserInteractionEvent.ACE_EDITOR, + userInteractionEvent: UserInteractionEvent.MONACO_EDITOR, }), new UserInterActionTourStep({ highlightSelector: '.guided-tour-save-button', diff --git a/src/main/webapp/app/orion/management/code-editor-instructor-and-editor-orion-container.component.ts b/src/main/webapp/app/orion/management/code-editor-instructor-and-editor-orion-container.component.ts index dabe5aac4009..fece685811c2 100644 --- a/src/main/webapp/app/orion/management/code-editor-instructor-and-editor-orion-container.component.ts +++ b/src/main/webapp/app/orion/management/code-editor-instructor-and-editor-orion-container.component.ts @@ -13,7 +13,8 @@ import { OrionBuildAndTestService } from 'app/shared/orion/orion-build-and-test. import { OrionState } from 'app/shared/orion/orion'; import { faCircleNotch, faTimesCircle } from '@fortawesome/free-solid-svg-icons'; import { CourseExerciseService } from 'app/exercises/shared/course-exercises/course-exercise.service'; -import { MarkdownEditorHeight } from 'app/shared/markdown-editor/markdown-editor.component'; + +import { MarkdownEditorHeight } from 'app/shared/markdown-editor/monaco/markdown-editor-monaco.component'; @Component({ selector: 'jhi-code-editor-instructor-orion', diff --git a/src/main/webapp/app/overview/course-exercises/course-exercises.component.html b/src/main/webapp/app/overview/course-exercises/course-exercises.component.html index 2de6ec14a991..6be118bf7475 100644 --- a/src/main/webapp/app/overview/course-exercises/course-exercises.component.html +++ b/src/main/webapp/app/overview/course-exercises/course-exercises.component.html @@ -1,7 +1,7 @@
@if (course) {
- +
@if (exerciseSelected) { diff --git a/src/main/webapp/app/overview/exercise-details/exercise-details-student-actions.component.html b/src/main/webapp/app/overview/exercise-details/exercise-details-student-actions.component.html index ea54cf40b8c2..b2d119d0cd2a 100644 --- a/src/main/webapp/app/overview/exercise-details/exercise-details-student-actions.component.html +++ b/src/main/webapp/app/overview/exercise-details/exercise-details-student-actions.component.html @@ -126,6 +126,7 @@ [participations]="exercise.studentParticipations!" [exercise]="exercise" [routerLinkForRepositoryView]="repositoryLink + '/repository/' + exercise.studentParticipations![0].id" + [useParticipationVcsAccessToken]="true" /> } @if (theiaEnabled) { diff --git a/src/main/webapp/app/shared/category-selector/category-selector.component.ts b/src/main/webapp/app/shared/category-selector/category-selector.component.ts index 03b24cac6ca1..d899a7b034b3 100644 --- a/src/main/webapp/app/shared/category-selector/category-selector.component.ts +++ b/src/main/webapp/app/shared/category-selector/category-selector.component.ts @@ -1,13 +1,12 @@ import { Component, ElementRef, EventEmitter, Input, OnChanges, Output, ViewChild, ViewEncapsulation } from '@angular/core'; import { ColorSelectorComponent } from 'app/shared/color-selector/color-selector.component'; import { ExerciseCategory } from 'app/entities/exercise-category.model'; -import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; +import { MatAutocompleteSelectedEvent, MatAutocompleteTrigger } from '@angular/material/autocomplete'; import { COMMA, ENTER, TAB } from '@angular/cdk/keycodes'; import { FormControl } from '@angular/forms'; import { MatChipInputEvent } from '@angular/material/chips'; import { Observable, map, startWith } from 'rxjs'; import { faTimes } from '@fortawesome/free-solid-svg-icons'; -import { MatAutocompleteTrigger } from '@angular/material/autocomplete'; const DEFAULT_COLORS = ['#6ae8ac', '#9dca53', '#94a11c', '#691b0b', '#ad5658', '#1b97ca', '#0d3cc2', '#0ab84f']; @@ -44,8 +43,7 @@ export class CategorySelectorComponent implements OnChanges { separatorKeysCodes = [ENTER, COMMA, TAB]; categoryCtrl = new FormControl(undefined); - // Icons - faTimes = faTimes; + readonly faTimes = faTimes; ngOnChanges() { this.uniqueCategoriesForAutocomplete = this.categoryCtrl.valueChanges.pipe( @@ -134,10 +132,7 @@ export class CategorySelectorComponent implements OnChanges { } private createCategory(categoryString: string): ExerciseCategory { - const category = new ExerciseCategory(); - category.category = categoryString; - category.color = this.chooseRandomColor(); - return category; + return new ExerciseCategory(categoryString, this.chooseRandomColor()); } private chooseRandomColor(): string { diff --git a/src/main/webapp/app/shared/components/code-button/code-button.component.html b/src/main/webapp/app/shared/components/code-button/code-button.component.html index 89d1b5aa952f..c9d791edac00 100644 --- a/src/main/webapp/app/shared/components/code-button/code-button.component.html +++ b/src/main/webapp/app/shared/components/code-button/code-button.component.html @@ -14,8 +14,14 @@ container="body" > - @if (useSsh && (!user.sshPublicKey || gitlabVCEnabled)) { -
+ @if (useSsh && !copyEnabled) { +
+ } + @if (useToken && !copyEnabled && tokenMissing) { +
+ } + @if (useToken && !copyEnabled && tokenExpired) { +
} @if (participations && participations.length > 1) {
@@ -34,7 +40,7 @@
{{ cloneHeadline | artemisTranslate }}
@if (showCloneUrlWithoutToken) { HTTPS } - @if (useVersionControlAccessToken && participations) { + @if (accessTokensEnabled && (!useParticipationVcsAccessToken || participations)) { Token } SSH @@ -50,7 +56,7 @@
{{ cloneHeadline | artemisTranslate }}
'url-box-remove-line-right': !localVCEnabled || !!routerLinkForRepositoryView, }" [cdkCopyToClipboard]="getHttpOrSshRepositoryUri(false)" - (cdkCopyToClipboardCopied)="onCopyFinished($event)" + (cdkCopyToClipboardCopied)="copyEnabled ? onCopyFinished($event) : null" >{{ getHttpOrSshRepositoryUri() }}
@@ -86,6 +92,7 @@
{{ cloneHeadline | artemisTranslate }}
+ } +
+
diff --git a/src/main/webapp/app/shared/exercise-categories/custom-exercise-category-badge/custom-exercise-category-badge.component.scss b/src/main/webapp/app/shared/exercise-categories/custom-exercise-category-badge/custom-exercise-category-badge.component.scss new file mode 100644 index 000000000000..43a2d70cca34 --- /dev/null +++ b/src/main/webapp/app/shared/exercise-categories/custom-exercise-category-badge/custom-exercise-category-badge.component.scss @@ -0,0 +1,18 @@ +.remove-button { + color: var(--white); + background: transparent !important; + border: none !important; + + &:hover { + opacity: 0.8; + } +} + +/** category options should be consistent with the type CategoryFontSize */ +.category-small { + font-size: 0.85rem; +} + +.category-default { + font-size: 1rem; +} diff --git a/src/main/webapp/app/shared/exercise-categories/custom-exercise-category-badge/custom-exercise-category-badge.component.ts b/src/main/webapp/app/shared/exercise-categories/custom-exercise-category-badge/custom-exercise-category-badge.component.ts new file mode 100644 index 000000000000..8ba41f96ba8b --- /dev/null +++ b/src/main/webapp/app/shared/exercise-categories/custom-exercise-category-badge/custom-exercise-category-badge.component.ts @@ -0,0 +1,23 @@ +import { Component, Input } from '@angular/core'; +import type { ExerciseCategory } from 'app/entities/exercise-category.model'; +import { CommonModule } from '@angular/common'; +import { faTimes } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; + +type CategoryFontSize = 'default' | 'small'; + +@Component({ + selector: 'jhi-custom-exercise-category-badge', + templateUrl: './custom-exercise-category-badge.component.html', + styleUrls: ['custom-exercise-category-badge.component.scss'], + standalone: true, + imports: [CommonModule, FontAwesomeModule], +}) +export class CustomExerciseCategoryBadgeComponent { + protected readonly faTimes = faTimes; + + @Input({ required: true }) category: ExerciseCategory; + @Input() displayRemoveButton: boolean = false; + @Input() onClick: () => void = () => {}; + @Input() fontSize: CategoryFontSize = 'default'; +} diff --git a/src/main/webapp/app/shared/exercise-categories/exercise-categories.component.html b/src/main/webapp/app/shared/exercise-categories/exercise-categories.component.html index dd757a2d5b36..f811a3f4c4e7 100644 --- a/src/main/webapp/app/shared/exercise-categories/exercise-categories.component.html +++ b/src/main/webapp/app/shared/exercise-categories/exercise-categories.component.html @@ -11,9 +11,7 @@

} @for (category of exercise.categories; track category) { -

- {{ category.category }} -

+ } @if (exercise.difficulty && showTags.difficulty) {

diff --git a/src/main/webapp/app/shared/exercise-categories/exercise-categories.module.ts b/src/main/webapp/app/shared/exercise-categories/exercise-categories.module.ts index f56611d32021..f0f8877ffdec 100644 --- a/src/main/webapp/app/shared/exercise-categories/exercise-categories.module.ts +++ b/src/main/webapp/app/shared/exercise-categories/exercise-categories.module.ts @@ -3,9 +3,10 @@ import { RouterModule } from '@angular/router'; import { ArtemisSharedModule } from 'app/shared/shared.module'; import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; import { ExerciseCategoriesComponent } from 'app/shared/exercise-categories/exercise-categories.component'; +import { CustomExerciseCategoryBadgeComponent } from 'app/shared/exercise-categories/custom-exercise-category-badge/custom-exercise-category-badge.component'; @NgModule({ - imports: [ArtemisSharedModule, RouterModule, ArtemisSharedComponentModule], + imports: [ArtemisSharedModule, RouterModule, ArtemisSharedComponentModule, CustomExerciseCategoryBadgeComponent], declarations: [ExerciseCategoriesComponent], exports: [ExerciseCategoriesComponent], }) diff --git a/src/main/webapp/app/shared/exercise-filter/exercise-filter-modal.component.html b/src/main/webapp/app/shared/exercise-filter/exercise-filter-modal.component.html new file mode 100644 index 000000000000..1a87237a4d46 --- /dev/null +++ b/src/main/webapp/app/shared/exercise-filter/exercise-filter-modal.component.html @@ -0,0 +1,145 @@ +
+ + + +
diff --git a/src/main/webapp/app/shared/exercise-filter/exercise-filter-modal.component.scss b/src/main/webapp/app/shared/exercise-filter/exercise-filter-modal.component.scss new file mode 100644 index 000000000000..c2db4383039b --- /dev/null +++ b/src/main/webapp/app/shared/exercise-filter/exercise-filter-modal.component.scss @@ -0,0 +1,20 @@ +/* +ensures that the category dropdown selection does not overflow the screen to the bottom +(instead a scrollbar will be displayed) + */ +:host ::ng-deep ngb-typeahead-window.dropdown-menu { + max-height: 25rem; + overflow-y: auto; +} + +/* align the first checkbox to the left */ +.no-left-margin-padding:first-child { + margin-left: 0; + padding-left: 0; +} + +/* otherwise the dropdown changes the color on hover if no further options can be selected */ +.form-control:disabled, +.form-control[disabled] { + pointer-events: none; +} diff --git a/src/main/webapp/app/shared/exercise-filter/exercise-filter-modal.component.ts b/src/main/webapp/app/shared/exercise-filter/exercise-filter-modal.component.ts new file mode 100644 index 000000000000..8b1bf7c58488 --- /dev/null +++ b/src/main/webapp/app/shared/exercise-filter/exercise-filter-modal.component.ts @@ -0,0 +1,231 @@ +import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'; +import { NgbActiveModal, NgbTypeahead } from '@ng-bootstrap/ng-bootstrap'; +import { faBackward, faFilter } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; +import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; +import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { SidebarCardElement, SidebarData } from 'app/types/sidebar'; +import { Observable, OperatorFunction, Subject, merge } from 'rxjs'; +import { debounceTime, distinctUntilChanged, filter, map } from 'rxjs/operators'; +import { CustomExerciseCategoryBadgeComponent } from 'app/shared/exercise-categories/custom-exercise-category-badge/custom-exercise-category-badge.component'; +import { RangeSliderComponent } from 'app/shared/range-slider/range-slider.component'; +import { + DifficultyFilterOption, + ExerciseCategoryFilterOption, + ExerciseFilterOptions, + ExerciseFilterResults, + ExerciseTypeFilterOption, + FilterDetails, + FilterOption, + RangeFilter, +} from 'app/types/exercise-filter'; +import { satisfiesFilters } from 'app/shared/exercise-filter/exercise-filter-modal.helper'; +import { DifficultyLevel, ExerciseType } from 'app/entities/exercise.model'; +import { ExerciseCategory } from 'app/entities/exercise-category.model'; +import { isRangeFilterApplied } from 'app/shared/sidebar/sidebar.helper'; + +@Component({ + selector: 'jhi-exercise-filter-modal', + templateUrl: './exercise-filter-modal.component.html', + styleUrls: ['./exercise-filter-modal.component.scss'], + standalone: true, + imports: [ + FormsModule, + ReactiveFormsModule, + FontAwesomeModule, + ArtemisSharedCommonModule, + ArtemisSharedComponentModule, + CustomExerciseCategoryBadgeComponent, + RangeSliderComponent, + ], +}) +export class ExerciseFilterModalComponent implements OnInit { + readonly faFilter = faFilter; + readonly faBackward = faBackward; + + @Output() filterApplied = new EventEmitter(); + + @ViewChild('categoriesFilterSelection', { static: false }) instance: NgbTypeahead; + + selectedCategoryOptions: ExerciseCategoryFilterOption[] = []; + selectableCategoryOptions: ExerciseCategoryFilterOption[] = []; + + noFiltersAvailable: boolean = false; + + focus$ = new Subject(); + click$ = new Subject(); + + form: FormGroup; + + model?: string; + + sidebarData?: SidebarData; + + categoryFilter?: FilterOption; + typeFilter?: FilterOption; + difficultyFilter?: FilterOption; + achievablePoints?: RangeFilter; + achievedScore?: RangeFilter; + + exerciseFilters?: ExerciseFilterOptions; + + constructor(private activeModal: NgbActiveModal) {} + + ngOnInit() { + this.categoryFilter = this.exerciseFilters?.categoryFilter; + this.typeFilter = this.exerciseFilters?.exerciseTypesFilter; + this.difficultyFilter = this.exerciseFilters?.difficultyFilter; + this.achievablePoints = this.exerciseFilters?.achievablePoints; + this.achievedScore = this.exerciseFilters?.achievedScore; + + this.noFiltersAvailable = !( + this.categoryFilter?.isDisplayed || + this.typeFilter?.isDisplayed || + this.difficultyFilter?.isDisplayed || + this.achievedScore?.isDisplayed || + this.achievablePoints?.isDisplayed + ); + + this.updateCategoryOptionsStates(); + } + + closeModal(): void { + this.activeModal.close(); + } + + search: OperatorFunction = (text$: Observable) => { + const debouncedText$ = text$.pipe(debounceTime(200), distinctUntilChanged()); + const clicksWithClosedPopup$ = this.click$.pipe(filter(() => !this.instance.isPopupOpen())); + const inputFocus$ = this.focus$; + + return merge(debouncedText$, inputFocus$, clicksWithClosedPopup$).pipe( + map((term) => + term === '' + ? this.selectableCategoryOptions + : this.selectableCategoryOptions.filter((categoryFilter: ExerciseCategoryFilterOption) => { + if (categoryFilter.category.category !== undefined) { + return categoryFilter.category.category?.toLowerCase().indexOf(term.toLowerCase()) > -1; + } + + return false; + }), + ), + ); + }; + resultFormatter = (exerciseCategory: ExerciseCategoryFilterOption) => exerciseCategory.category.category ?? ''; + + onSelectItem(event: any) { + const isEnterPressedForNotExistingItem = !event.item; + if (isEnterPressedForNotExistingItem) { + event.preventDefault(); + event.stopPropagation(); + return; + } + + event.preventDefault(); // otherwise clearing the input field will not work https://stackoverflow.com/questions/39783936/how-to-clear-the-typeahead-input-after-a-result-is-selected + const filterOption: ExerciseCategoryFilterOption = event.item; + filterOption.searched = true; + this.updateCategoryOptionsStates(); + this.model = undefined; // Clear the input field after selection + } + + removeItem(item: ExerciseCategoryFilterOption): () => void { + return () => { + item.searched = false; + this.updateCategoryOptionsStates(); + }; + } + + applyFilter(): void { + if (!this.sidebarData?.groupedData) { + return; + } + + const appliedFilterDetails = this.getAppliedFilterDetails(); + for (const groupedDataKey in this.sidebarData.groupedData) { + this.sidebarData.groupedData[groupedDataKey].entityData = this.sidebarData.groupedData[groupedDataKey].entityData.filter((sidebarElement) => + satisfiesFilters(sidebarElement, appliedFilterDetails), + ); + } + this.sidebarData.ungroupedData = this.sidebarData.ungroupedData?.filter((sidebarElement: SidebarCardElement) => satisfiesFilters(sidebarElement, appliedFilterDetails)); + + this.filterApplied.emit({ + filteredSidebarData: this.sidebarData, + appliedExerciseFilters: this.exerciseFilters, + isFilterActive: this.isFilterActive(appliedFilterDetails), + }); + + this.closeModal(); + } + + private getAppliedFilterDetails(): FilterDetails { + return { + searchedTypes: this.getSearchedTypes(), + selectedCategories: this.getSelectedCategories(), + searchedDifficulties: this.getSearchedDifficulties(), + isScoreFilterApplied: isRangeFilterApplied(this.achievedScore), + isPointsFilterApplied: isRangeFilterApplied(this.achievablePoints), + achievedScore: this.achievedScore, + achievablePoints: this.achievablePoints, + }; + } + + private getSearchedTypes(): ExerciseType[] | undefined { + return this.typeFilter?.options.filter((type) => type.checked).map((type) => type.value); + } + + private getSelectedCategories(): ExerciseCategory[] { + return this.selectedCategoryOptions + .filter((categoryOption: ExerciseCategoryFilterOption) => categoryOption.searched) + .map((categoryOption: ExerciseCategoryFilterOption) => categoryOption.category); + } + + private getSearchedDifficulties(): DifficultyLevel[] | undefined { + return this.difficultyFilter?.options.filter((difficulty) => difficulty.checked).map((difficulty) => difficulty.value); + } + + private isFilterActive(filterDetails: FilterDetails): boolean { + return ( + !!filterDetails.selectedCategories.length || + !!filterDetails.searchedTypes?.length || + !!filterDetails.searchedDifficulties?.length || + filterDetails.isScoreFilterApplied || + filterDetails.isPointsFilterApplied + ); + } + + clearFilter() { + this.categoryFilter?.options.forEach((categoryOption) => (categoryOption.searched = false)); + this.typeFilter?.options.forEach((typeOption) => (typeOption.checked = false)); + this.difficultyFilter?.options.forEach((difficultyOption) => (difficultyOption.checked = false)); + + this.resetRangeFilter(this.achievedScore); + this.resetRangeFilter(this.achievablePoints); + + this.applyFilter(); + } + + private resetRangeFilter(rangeFilter?: RangeFilter) { + if (!rangeFilter?.filter) { + return; + } + + const filter = rangeFilter.filter; + filter.selectedMin = filter.generalMin; + filter.selectedMax = filter.generalMax; + } + + private updateCategoryOptionsStates() { + this.selectedCategoryOptions = this.getUpdatedSelectedCategoryOptions(); + this.selectableCategoryOptions = this.getSelectableCategoryOptions(); + } + + private getUpdatedSelectedCategoryOptions(): ExerciseCategoryFilterOption[] { + return this.categoryFilter?.options.filter((categoryFilter) => categoryFilter.searched) ?? []; + } + + private getSelectableCategoryOptions(): ExerciseCategoryFilterOption[] { + return this.categoryFilter?.options.filter((categoryFilter) => !categoryFilter.searched) ?? []; + } +} diff --git a/src/main/webapp/app/shared/exercise-filter/exercise-filter-modal.helper.ts b/src/main/webapp/app/shared/exercise-filter/exercise-filter-modal.helper.ts new file mode 100644 index 000000000000..de922fce195d --- /dev/null +++ b/src/main/webapp/app/shared/exercise-filter/exercise-filter-modal.helper.ts @@ -0,0 +1,84 @@ +import { SidebarCardElement } from 'app/types/sidebar'; +import { DifficultyLevel, ExerciseType } from 'app/entities/exercise.model'; +import { ExerciseCategory } from 'app/entities/exercise-category.model'; +import { FilterDetails, RangeFilter } from 'app/types/exercise-filter'; +import { getLatestResultOfStudentParticipation } from 'app/exercises/shared/participation/participation.utils'; + +export function satisfiesDifficultyFilter(sidebarElement: SidebarCardElement, searchedDifficulties?: DifficultyLevel[]): boolean { + if (!searchedDifficulties?.length) { + return true; + } + if (!sidebarElement.difficulty) { + return false; + } + + return searchedDifficulties.includes(sidebarElement.difficulty); +} + +export function satisfiesTypeFilter(sidebarElement: SidebarCardElement, searchedTypes?: ExerciseType[]): boolean { + if (!searchedTypes?.length) { + return true; + } + if (!sidebarElement.exercise?.type) { + return false; + } + + return searchedTypes.includes(sidebarElement.exercise.type); +} + +export function satisfiesCategoryFilter(sidebarElement: SidebarCardElement, selectedCategories: ExerciseCategory[]): boolean { + if (!selectedCategories.length) { + return true; + } + if (!sidebarElement?.exercise?.categories) { + return false; + } + + // noinspection UnnecessaryLocalVariableJS: not inlined because the variable name improves readability + const isAnyExerciseCategoryMatchingASelectedCategory = sidebarElement.exercise.categories.some((category) => + selectedCategories.some((selectedCategory) => selectedCategory.equals(category)), + ); + return isAnyExerciseCategoryMatchingASelectedCategory; +} + +export function satisfiesScoreFilter(sidebarElement: SidebarCardElement, isFilterApplied: boolean, achievedScoreFilter?: RangeFilter): boolean { + if (!isFilterApplied || !achievedScoreFilter) { + return true; + } + + const latestResult = getLatestResultOfStudentParticipation(sidebarElement.studentParticipation, true); + if (!latestResult?.score) { + return achievedScoreFilter.filter.selectedMin === 0; + } + + const isScoreInSelectedMinRange = latestResult.score >= achievedScoreFilter.filter.selectedMin; + const isScoreInSelectedMaxRange = latestResult.score <= achievedScoreFilter.filter.selectedMax; + + return isScoreInSelectedMinRange && isScoreInSelectedMaxRange; +} + +export function satisfiesPointsFilter(sidebarElement: SidebarCardElement, isPointsFilterApplied: boolean, achievablePointsFilter?: RangeFilter): boolean { + if (!isPointsFilterApplied || !achievablePointsFilter) { + return true; + } + + /** {@link Exercise.maxPoints} must be in the range 1 - 9999 */ + if (!sidebarElement.exercise?.maxPoints) { + return false; + } + + const isAchievablePointsInSelectedMinRange = sidebarElement.exercise.maxPoints >= achievablePointsFilter.filter.selectedMin; + const isAchievablePointsInSelectedMaxRange = sidebarElement.exercise.maxPoints <= achievablePointsFilter.filter.selectedMax; + + return isAchievablePointsInSelectedMinRange && isAchievablePointsInSelectedMaxRange; +} + +export function satisfiesFilters(sidebarElement: SidebarCardElement, filterDetails: FilterDetails) { + return ( + satisfiesCategoryFilter(sidebarElement, filterDetails.selectedCategories) && + satisfiesDifficultyFilter(sidebarElement, filterDetails.searchedDifficulties) && + satisfiesTypeFilter(sidebarElement, filterDetails.searchedTypes) && + satisfiesScoreFilter(sidebarElement, filterDetails.isScoreFilterApplied, filterDetails.achievedScore) && + satisfiesPointsFilter(sidebarElement, filterDetails.isPointsFilterApplied, filterDetails.achievablePoints) + ); +} diff --git a/src/main/webapp/app/shared/markdown-editor/ace-editor/ace-editor.component.ts b/src/main/webapp/app/shared/markdown-editor/ace-editor/ace-editor.component.ts deleted file mode 100644 index d0ddebb5007b..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/ace-editor/ace-editor.component.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { Component, ElementRef, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output, forwardRef } from '@angular/core'; -import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; -import 'brace'; -import 'brace/theme/monokai'; -import 'brace/theme/chrome'; -import 'brace/theme/dreamweaver'; -import 'brace/theme/dracula'; -import 'brace/ext/language_tools'; -import 'brace/ext/modelist'; -import 'brace/mode/java'; -import 'brace/mode/sh'; -import 'brace/mode/markdown'; -import 'brace/mode/haskell'; -import 'brace/mode/ocaml'; -import 'brace/mode/c_cpp'; -import 'brace/mode/python'; -import 'brace/mode/swift'; -import 'brace/mode/yaml'; -import 'brace/mode/makefile'; -import 'brace/mode/kotlin'; -import 'brace/mode/assembly_x86'; -import 'brace/mode/vhdl'; -import { ThemeService } from 'app/core/theme/theme.service'; -import { Subscription } from 'rxjs'; - -declare let ace: any; - -export const MAX_TAB_SIZE = 8; - -@Component({ - selector: 'jhi-ace-editor', - template: '', - styles: [':host { display:block;width:100%; }'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => AceEditorComponent), - multi: true, - }, - ], -}) -export class AceEditorComponent implements ControlValueAccessor, OnInit, OnDestroy { - @Output() textChanged = new EventEmitter(); - @Output() textChange = new EventEmitter(); - @Input() style: any = {}; - - /** - * Sets the size in spaces of newly inserted tabs and the display size of existing true tabs. - * - * @param value The display width between 1 and {@link MAX_TAB_SIZE} (both inclusive). - */ - @Input() - public set tabSize(value: number) { - if (value > 0 && value <= MAX_TAB_SIZE) { - this._editor.session.setTabSize(value); - } - } - - oldText: string; - timeoutSaving: any; - - private _options: any = {}; - private _readOnly = false; - private _theme = 'dreamweaver'; - private _mode = 'java'; - private _autoUpdateContent = true; - private _editor: any; // TODO: use Editor (defined in brace) or Editor (defined in ace-builds) and make sure to use typings consistently - private _durationBeforeCallback = 0; - private _text = ''; - - private themeSubscription: Subscription; - - constructor( - elementRef: ElementRef, - private zone: NgZone, - private themeService: ThemeService, - ) { - const el = elementRef.nativeElement; - this.zone.runOutsideAngular(() => { - this._editor = ace['edit'](el); - }); - this._editor.$blockScrolling = Infinity; - } - - ngOnInit() { - this.init(); - this.initEvents(); - } - - ngOnDestroy() { - this._editor.destroy(); - this.themeSubscription?.unsubscribe(); - } - - init() { - this.setOptions(this._options || {}); - this.setMode(this._mode); - this.setReadOnly(this._readOnly); - } - - initEvents() { - this._editor.on('change', () => this.updateText()); - this._editor.on('paste', () => this.updateText()); - - this.themeSubscription = this.themeService.getCurrentThemeObservable().subscribe(() => this.setThemeFromMode()); - } - - updateText() { - const newVal = this._editor.getValue(); - if (newVal === this.oldText) { - return; - } - if (!this._durationBeforeCallback) { - this._text = newVal; - this.zone.run(() => { - this.textChange.emit(newVal); - this.textChanged.emit(newVal); - }); - this._onChange(newVal); - } else { - if (this.timeoutSaving) { - clearTimeout(this.timeoutSaving); - } - - this.timeoutSaving = setTimeout(() => { - this._text = newVal; - this.zone.run(() => { - this.textChange.emit(newVal); - this.textChanged.emit(newVal); - }); - this.timeoutSaving = null; - }, this._durationBeforeCallback); - } - this.oldText = newVal; - } - - @Input() - set options(options: any) { - this.setOptions(options); - } - - setOptions(options: any) { - this._options = options; - this._editor.setOptions(options || {}); - } - - @Input() - set readOnly(readOnly: boolean) { - this.setReadOnly(readOnly); - } - - setReadOnly(readOnly: boolean) { - this._readOnly = readOnly; - this._editor.setReadOnly(readOnly); - } - - @Input() - set mode(mode: string) { - this.setMode(mode); - } - - setMode(mode: string) { - this._mode = mode; - if (typeof this._mode === 'object') { - this._editor.getSession().setMode(this._mode); - } else { - this._editor.getSession().setMode(`ace/mode/${this._mode}`); - } - - this.setThemeFromMode(); - } - - private setThemeFromMode() { - const currentApplicationTheme = this.themeService.getCurrentTheme(); - this._theme = this._mode.toLowerCase() === 'markdown' ? currentApplicationTheme.markdownAceTheme : currentApplicationTheme.codeAceTheme; - this._editor.setTheme(`ace/theme/${this._theme}`); - } - - get value() { - return this.text; - } - - @Input() - set value(value: string) { - this.setText(value); - } - - writeValue(value: string) { - this.setText(value); - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - private _onChange = (_: any) => {}; - - registerOnChange(fn: any) { - this._onChange = fn; - } - - private _onTouched = () => {}; - - registerOnTouched(fn: any) { - this._onTouched = fn; - } - - get text() { - return this._text; - } - - @Input() - set text(text: string) { - this.setText(text); - } - - setText(text: string) { - if (text == undefined) { - text = ''; - } - if (this._text !== text && this._autoUpdateContent) { - this._text = text; - this._editor.setValue(text); - this._onChange(text); - this._editor.clearSelection(); - } - } - - @Input() - set autoUpdateContent(status: boolean) { - this.setAutoUpdateContent(status); - } - - setAutoUpdateContent(status: boolean) { - this._autoUpdateContent = status; - } - - @Input() - set durationBeforeCallback(num: number) { - this.setDurationBeforeCallback(num); - } - - setDurationBeforeCallback(num: number) { - this._durationBeforeCallback = num; - } - - getEditor() { - return this._editor; - } -} diff --git a/src/main/webapp/app/shared/markdown-editor/ace-editor/ace-editor.module.ts b/src/main/webapp/app/shared/markdown-editor/ace-editor/ace-editor.module.ts deleted file mode 100644 index 4962eeb93346..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/ace-editor/ace-editor.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { NgModule } from '@angular/core'; -import { AceEditorComponent } from 'app/shared/markdown-editor/ace-editor/ace-editor.component'; - -@NgModule({ - declarations: [AceEditorComponent], - exports: [AceEditorComponent], -}) -export class AceEditorModule {} diff --git a/src/main/webapp/app/shared/markdown-editor/command-constants.ts b/src/main/webapp/app/shared/markdown-editor/command-constants.ts deleted file mode 100644 index a65e67529016..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/command-constants.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { ReferenceType } from 'app/shared/metis/metis.util'; - -export type ValueItem = { - id: string; - value: string; - type?: string; - elements?: ValueItem[]; - attachmentUnits?: ValueItem[]; -}; - -export type SlideItem = { - id: string; - slideImagePath: string; - slideNumber: number; - courseArtifactType: ReferenceType; -}; diff --git a/src/main/webapp/app/shared/markdown-editor/commands/attachmentCommand.ts b/src/main/webapp/app/shared/markdown-editor/commands/attachmentCommand.ts deleted file mode 100644 index 19dcfaa8d522..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/commands/attachmentCommand.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { IconProp } from '@fortawesome/fontawesome-svg-core'; -import { faImage } from '@fortawesome/free-solid-svg-icons'; -import { Command } from './command'; - -export class AttachmentCommand extends Command { - buttonIcon = faImage as IconProp; - buttonTranslationString = 'artemisApp.multipleChoiceQuestion.editor.imageUpload'; - - /** - * @function execute - * @desc insert/remove the markdown command for uploading an attachment - * 1. Check if the selected text includes ('![](http://)') - * 2. If included reduce the selected text by this elements and replace the selected text by textToAdd - * 3. If not included add ('![](http://)') at the cursor position in the editor - * 4. Attachment in Markdown language appears - */ - execute(): void { - let selectedText = this.getSelectedText(); - - if (selectedText.includes('![](http://)')) { - const textToAdd = selectedText.slice(12); - this.insertText(textToAdd); - } else { - const range = this.getRange(); - selectedText = `![](http://)`; - this.replace(range, selectedText); - this.focus(); - } - } -} diff --git a/src/main/webapp/app/shared/markdown-editor/commands/bold.command.ts b/src/main/webapp/app/shared/markdown-editor/commands/bold.command.ts deleted file mode 100644 index 3ebf66c361a6..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/commands/bold.command.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { IconProp } from '@fortawesome/fontawesome-svg-core'; -import { faBold } from '@fortawesome/free-solid-svg-icons'; -import { Command } from './command'; - -export class BoldCommand extends Command { - buttonIcon = faBold as IconProp; - buttonTranslationString = 'artemisApp.multipleChoiceQuestion.editor.bold'; - - /** - * @function execute - * @desc Make/Remove bold text - * 1. Check if the selected text includes (**) - * 2. If included reduce the selected text by these elements and replace the selected text by textToAdd - * 3. If not included, add (**) before and after the selected text and insert them into the editor - * 4. Bold markdown appears - */ - execute(): void { - const selectedText = this.getSelectedText(); - let textToAdd = ''; - - if (selectedText.slice(0, 2) === '**' && selectedText.slice(selectedText.length - 2, selectedText.length) === '**') { - textToAdd = selectedText.slice(2, -2); - this.insertText(textToAdd); - } else { - const trimmedText = this.deleteWhiteSpace(selectedText); - textToAdd = `**${trimmedText}**`; - this.addRefinedText(selectedText, textToAdd); - } - } -} diff --git a/src/main/webapp/app/shared/markdown-editor/commands/code.command.ts b/src/main/webapp/app/shared/markdown-editor/commands/code.command.ts deleted file mode 100644 index 37382b93d663..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/commands/code.command.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { IconProp } from '@fortawesome/fontawesome-svg-core'; -import { faCode } from '@fortawesome/free-solid-svg-icons'; -import { Command } from './command'; - -export class CodeCommand extends Command { - buttonIcon = faCode as IconProp; - buttonTranslationString = 'artemisApp.multipleChoiceQuestion.editor.code'; - - /** - * @function execute - * @desc 1. check if the selected text starts with '`' and ends with '`' - * 2. if it does include those elements reduce the selected text by this elements and add replace the selected text by the reduced text - * 3. if it does not include those add (`) before and after the selected text and add them to the text editor - * 4. code markdown appears - */ - execute(): void { - let selectedText = this.getSelectedText(); - - if (selectedText.startsWith('`') && selectedText.endsWith('`')) { - const textToAdd = selectedText.slice(1, -1); - this.insertText(textToAdd); - } else { - const range = this.getRange(); - selectedText = '`' + selectedText + '`'; - this.replace(range, selectedText); - this.focus(); - } - } -} diff --git a/src/main/webapp/app/shared/markdown-editor/commands/codeblock.command.ts b/src/main/webapp/app/shared/markdown-editor/commands/codeblock.command.ts deleted file mode 100644 index 7adeb548b581..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/commands/codeblock.command.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { IconProp } from '@fortawesome/fontawesome-svg-core'; -import { faFileCode } from '@fortawesome/free-solid-svg-icons'; -import { Command } from './command'; - -export class CodeBlockCommand extends Command { - buttonIcon = faFileCode as IconProp; - buttonTranslationString = 'artemisApp.multipleChoiceQuestion.editor.codeBlock'; - - /** - * @function execute - * @desc add (```java) before and (```) after the selected text - */ - execute(): void { - let selectedText = this.getSelectedText(); - const range = this.getRange(); - const initText = 'Source Code'; - selectedText = '```java\n' + (selectedText || initText) + '\n```'; - this.replace(range, selectedText); - this.focus(); - } -} diff --git a/src/main/webapp/app/shared/markdown-editor/commands/colorPicker.command.ts b/src/main/webapp/app/shared/markdown-editor/commands/colorPicker.command.ts deleted file mode 100644 index a311650ac3bb..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/commands/colorPicker.command.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { IconProp } from '@fortawesome/fontawesome-svg-core'; -import { Command } from './command'; - -export class ColorPickerCommand extends Command { - buttonIcon = '' as IconProp; - buttonTranslationString = 'artemisApp.multipleChoiceQuestion.editor.color'; - - /** - * @function execute - * @desc Set/ Remove color - * 1. Check if the selected text includes (` + `${selectedText}` + ``; - this.insertText(textToAdd); - break; - case '#3ea119': - textToAdd = `` + `${selectedText}` + ``; - this.insertText(textToAdd); - break; - case '#ffffff': - textToAdd = `` + `${selectedText}` + ``; - this.insertText(textToAdd); - break; - case '#000000': - textToAdd = `${selectedText}`; - // this.execute('#000000'); - // textToAdd = `` + `${selectedText}` + ``; - this.insertText(textToAdd); - break; - case '#fffa5c': - textToAdd = `` + `${selectedText}` + ``; - this.insertText(textToAdd); - break; - case '#0d3cc2': - textToAdd = `` + `${selectedText}` + ``; - this.insertText(textToAdd); - break; - case '#b05db8': - textToAdd = `` + `${selectedText}` + ``; - this.insertText(textToAdd); - break; - case '#d86b1f': - textToAdd = `` + `${selectedText}` + ``; - this.insertText(textToAdd); - break; - } - } -} diff --git a/src/main/webapp/app/shared/markdown-editor/commands/command.ts b/src/main/webapp/app/shared/markdown-editor/commands/command.ts deleted file mode 100644 index af44c39a9136..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/commands/command.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { ElementRef } from '@angular/core'; -import { IconProp } from '@fortawesome/fontawesome-svg-core'; -import { Range, acequire } from 'brace'; - -// Work around to update the range -const RangeCtor = acequire('ace/range').Range as typeof Range; - -/** - * abstract class for all commands - default and domain commands of Artemis - * default commands: markdown commands e.g. bold, italic - * domain commands: Artemis customized commands - */ -export abstract class Command { - buttonIcon: IconProp; - buttonTranslationString: string; - protected aceEditor: any; - protected markdownWrapper: ElementRef; - - public setEditor(aceEditor: any): void { - this.aceEditor = aceEditor; - } - - public setMarkdownWrapper(ref: ElementRef) { - this.markdownWrapper = ref; - } - - protected getSelectedText(): string { - return this.aceEditor.getSelectedText(); - } - - /** - * Extends the current selection to full lines. - * - * @return The complete lines of the selected text. - */ - protected getExtendedSelectedText(): string[] { - const text = this.getText(); - - // Split text by line breaks - const lines = text.split('\n'); - - const range = this.getRange(); - - // Update the range - this.aceEditor.selection.setRange(new RangeCtor(range.start.row, 0, range.end.row, lines[range.end.row].length)); - - // Return extended selection as array - return lines.slice(range.start.row, range.end.row + 1); - } - - protected getText(): string { - return this.aceEditor.getValue(); - } - - protected insertText(text: string) { - this.aceEditor.insert(text); - } - - protected focus() { - this.aceEditor.focus(); - } - - protected getRange(): Range { - return this.aceEditor.selection.getRange(); - } - - protected replace(range: Range, text: string) { - this.aceEditor.session.replace(range, text); - } - - protected clearSelection() { - this.aceEditor.clearSelection(); - } - - protected moveCursorTo(row: number, column: number) { - this.aceEditor.moveCursorTo(row, column); - } - - protected getCursorPosition() { - return this.aceEditor.getCursorPosition(); - } - - protected getLine(row: number) { - return this.aceEditor.getSession().getLine(row); - } - - protected getCurrentLine() { - const cursor = this.getCursorPosition(); - return this.getLine(cursor.row); - } - - protected moveCursorToEndOfRow() { - const cursor = this.getCursorPosition(); - const currentLine = this.aceEditor.getSession().getLine(cursor.row); - this.clearSelection(); - this.moveCursorTo(cursor.row, currentLine.length); - } - - protected addCompleter(completer: any) { - this.aceEditor.completers = [...(this.aceEditor.completers || []), completer]; - } - - abstract execute(input?: string): void; - - protected deleteWhiteSpace(text: string) { - return text.trim(); - } - - protected addRefinedText(selectedText: string, textToAdd: string) { - if (selectedText.charAt(0) === ' ' && selectedText.charAt(selectedText.length - 1) === ' ') { - return this.insertText(' ' + textToAdd + ' '); - } else if (selectedText.charAt(0) === ' ') { - return this.insertText(' ' + textToAdd); - } else if (selectedText.charAt(selectedText.length - 1) === ' ') { - return this.insertText(textToAdd + ' '); - } else { - return this.insertText(textToAdd); - } - } -} diff --git a/src/main/webapp/app/shared/markdown-editor/commands/courseArtifactReferenceCommands/channelMentionCommand.ts b/src/main/webapp/app/shared/markdown-editor/commands/courseArtifactReferenceCommands/channelMentionCommand.ts deleted file mode 100644 index 58241d8b73de..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/commands/courseArtifactReferenceCommands/channelMentionCommand.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { InteractiveSearchCommand } from 'app/shared/markdown-editor/commands/interactiveSearchCommand'; -import { faHashtag } from '@fortawesome/free-solid-svg-icons'; -import { HttpResponse } from '@angular/common/http'; -import { Observable, map, of } from 'rxjs'; -import { MetisService } from 'app/shared/metis/metis.service'; -import { ChannelIdAndNameDTO } from 'app/entities/metis/conversation/channel.model'; -import { ChannelService } from 'app/shared/metis/conversations/channel.service'; - -export class ChannelMentionCommand extends InteractiveSearchCommand { - buttonIcon = faHashtag; - - private cachedResponse: HttpResponse; - - constructor( - private readonly channelService: ChannelService, - private readonly metisService: MetisService, - ) { - super(); - } - - protected getAssociatedInputCharacter(): string { - return '#'; - } - - performSearch(searchTerm: string): Observable> { - // all channels are returned within a response. Therefore, the command can cache it - if (this.cachedResponse) { - return of(this.filterCachedResponse(searchTerm)); - } - return this.channelService.getPublicChannelsOfCourse(this.metisService.getCourse().id!).pipe( - map((response) => { - this.cachedResponse = response; - return this.filterCachedResponse(searchTerm); - }), - ); - } - - protected selectionToText(selected: ChannelIdAndNameDTO): string { - return `[channel]${selected['name'] ?? 'empty'}(${selected.id})[/channel]`; - } - - private filterCachedResponse(searchTerm: string): HttpResponse { - const channels = this.cachedResponse.body!.filter((dto) => dto.name?.toLowerCase().includes(searchTerm.toLowerCase())); - return new HttpResponse({ body: channels }); - } -} diff --git a/src/main/webapp/app/shared/markdown-editor/commands/courseArtifactReferenceCommands/exerciseReferenceCommand.ts b/src/main/webapp/app/shared/markdown-editor/commands/courseArtifactReferenceCommands/exerciseReferenceCommand.ts deleted file mode 100644 index ba955b868eb9..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/commands/courseArtifactReferenceCommands/exerciseReferenceCommand.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { MultiOptionCommand } from 'app/shared/markdown-editor/commands/multiOptionCommand'; -import { MetisService } from 'app/shared/metis/metis.service'; - -export class ExerciseReferenceCommand extends MultiOptionCommand { - metisService: MetisService; - - buttonTranslationString = 'artemisApp.metis.editor.exercise'; - - constructor(metisService: MetisService) { - super(); - this.metisService = metisService; - - this.setValues( - // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain - this.metisService.getCourse().exercises?.map((exercise) => ({ - id: exercise.id!.toString(), - value: exercise.title!, - type: exercise.type, - }))!, - ); - } - - /** - * @function execute - * @param {string} selectedExerciseId ID of the exercise to be referenced - * @desc Add an exercise reference link in markdown language - * 1. Add '[{exercise-title}](/courses/{courseId}/exercises/{exerciseId}})' at the cursor in the editor - * 2. Link in markdown language appears which when clicked navigates to the exercise page - */ - execute(selectedExerciseId: string): void { - const selectedExercise = this.getValues().find((value) => value.id.toString() === selectedExerciseId)!; - const referenceLink = `[${selectedExercise.type}]${selectedExercise.value}(${this.metisService.getLinkForExercise(selectedExercise.id)})[/${selectedExercise.type}]`; - this.insertText(referenceLink); - this.focus(); - } -} diff --git a/src/main/webapp/app/shared/markdown-editor/commands/courseArtifactReferenceCommands/lectureAttachmentReferenceCommand.ts b/src/main/webapp/app/shared/markdown-editor/commands/courseArtifactReferenceCommands/lectureAttachmentReferenceCommand.ts deleted file mode 100644 index 8a1cce16bd2e..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/commands/courseArtifactReferenceCommands/lectureAttachmentReferenceCommand.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { MultiOptionCommand } from 'app/shared/markdown-editor/commands/multiOptionCommand'; -import { MetisService } from 'app/shared/metis/metis.service'; -import { ReferenceType } from 'app/shared/metis/metis.util'; -import { LectureService } from 'app/lecture/lecture.service'; -import { Lecture } from 'app/entities/lecture.model'; -import { map } from 'rxjs/operators'; -import { HttpResponse } from '@angular/common/http'; -import { AttachmentUnit } from 'app/entities/lecture-unit/attachmentUnit.model'; -import { Slide } from 'app/entities/lecture-unit/slide.model'; -import { Attachment } from 'app/entities/attachment.model'; -import { SlideItem, ValueItem } from 'app/shared/markdown-editor/command-constants'; -import { LectureUnit } from 'app/entities/lecture-unit/lectureUnit.model'; - -export class LectureAttachmentReferenceCommand extends MultiOptionCommand { - metisService: MetisService; - - lectureService: LectureService; - - buttonTranslationString = 'artemisApp.metis.editor.lecture'; - - constructor(metisService: MetisService, lectureService: LectureService) { - super(); - this.metisService = metisService; - this.lectureService = lectureService; - - lectureService - .findAllByCourseIdWithSlides(this.metisService.getCourse().id!) - .pipe(map((response: HttpResponse) => response.body!)) - .subscribe((lectures: Lecture[]) => { - lectures.map((lecture) => { - this.setValues([ - ...this.values, - { - id: lecture.id!.toString(), - value: lecture.title!, - type: ReferenceType.LECTURE, - elements: this.lectureAttachments(lecture.attachments!), - attachmentUnits: this.attachmentUnitsWithSlides(lecture.lectureUnits!), - }, - ]); - }); - }); - } - - /** - * @function execute - * @param {string} selectedLectureId ID of the lecture to be referenced - * @param type - * @param selectedElementId - * @param selectedUnitId - * @param selectedSlideId - * @desc Add a lecture reference link in markdown language - * 1. Add '[{lecture-title}](/courses/{courseId}/lectures/{lectureId}})' at the cursor in the editor - * 2. Link in markdown language appears which when clicked navigates to the lecture page - */ - execute(selectedLectureId: string, type?: ReferenceType, selectedElementId?: string, selectedUnitId?: string, selectedSlideId?: string): void { - const selectedLecture = this.metisService.getCourse().lectures!.find((value) => value.id!.toString() === selectedLectureId)!; - this.lectureService - .findWithDetailsWithSlides(selectedLecture.id!) - .pipe(map((response: HttpResponse) => response.body!)) - .subscribe({ - next: (lecture: Lecture) => { - if (selectedUnitId) { - if (!selectedSlideId) { - // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain - const selectedUnit: AttachmentUnit = lecture.lectureUnits?.find((value: AttachmentUnit) => value.id!.toString() === selectedUnitId)!; - const shortLink = selectedUnit.attachment?.link!.split('attachments/')[1]; - const referenceLink = `[lecture-unit]${selectedUnit.name}(${shortLink})[/lecture-unit]`; - this.insertText(referenceLink); - this.focus(); - } else { - // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain - const selectedUnit: AttachmentUnit = lecture.lectureUnits?.find((value: AttachmentUnit) => value.id!.toString() === selectedUnitId)!; - // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain - const selectedSlide: Slide = selectedUnit.slides?.find((value: Slide) => value.id!.toString() === selectedSlideId)!; - const shortLink = selectedSlide.slideImagePath!.split('attachments/')[1]; - // Use a regular expression and the replace() method to remove the file name and last slash - const shortLinkWithoutFileName: string = shortLink.replace(new RegExp(`[^/]*${'.png'}`), '').replace(/\/$/, ''); - const referenceLink = `[slide]${selectedUnit.name} Slide ${selectedSlide.slideNumber}(${shortLinkWithoutFileName})[/slide]`; - this.insertText(referenceLink); - this.focus(); - } - } else { - // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain - const selectedAttachment = selectedLecture.attachments?.find((value) => value.id!.toString() === selectedElementId)!; - const shortLink = selectedAttachment ? selectedAttachment.link!.split('attachments/')[1] : ''; - const referenceLink = - ReferenceType.LECTURE === type - ? `[lecture]${selectedLecture.title}(${this.metisService.getLinkForLecture(selectedLecture.id!.toString())})[/lecture]` - : `[attachment]${selectedAttachment.name}(${shortLink})[/attachment]`; - this.insertText(referenceLink); - this.focus(); - } - }, - }); - } - - private lectureAttachments(attachments: Attachment[]): ValueItem[] { - return attachments?.map((attachment: any) => ({ - id: attachment.id!.toString(), - value: attachment.name!, - courseArtifactType: ReferenceType.ATTACHMENT, - })); - } - - private attachmentUnitsWithSlides(lectureUnits: LectureUnit[]): ValueItem[] { - return lectureUnits?.map((unit: any) => { - return { - id: unit.id!.toString(), - value: unit.name!, - slides: this.attachmentUnitSlides(unit.slides!), - courseArtifactType: ReferenceType.ATTACHMENT_UNITS, - }; - }); - } - - private attachmentUnitSlides(slides: Slide[]): SlideItem[] { - return slides - ?.map((slide: Slide) => { - return { - id: slide.id!.toString(), - slideNumber: slide.slideNumber!, - slideImagePath: slide.slideImagePath!, - courseArtifactType: ReferenceType.SLIDE, - }; - }) - .sort((a: SlideItem, b: SlideItem) => a.slideNumber - b.slideNumber); - } -} diff --git a/src/main/webapp/app/shared/markdown-editor/commands/courseArtifactReferenceCommands/userMentionCommand.ts b/src/main/webapp/app/shared/markdown-editor/commands/courseArtifactReferenceCommands/userMentionCommand.ts deleted file mode 100644 index 16dfcbbf7f2f..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/commands/courseArtifactReferenceCommands/userMentionCommand.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { InteractiveSearchCommand } from 'app/shared/markdown-editor/commands/interactiveSearchCommand'; -import { faAt } from '@fortawesome/free-solid-svg-icons'; -import { MetisService } from 'app/shared/metis/metis.service'; -import { ConversationUserDTO } from 'app/entities/metis/conversation/conversation-user-dto.model'; -import { HttpResponse } from '@angular/common/http'; -import { Observable } from 'rxjs'; -import { CourseManagementService } from 'app/course/manage/course-management.service'; - -export class UserMentionCommand extends InteractiveSearchCommand { - buttonIcon = faAt; - - constructor( - private readonly courseManagementService: CourseManagementService, - private readonly metisService: MetisService, - ) { - super(); - } - - protected getAssociatedInputCharacter(): string { - return '@'; - } - - performSearch(searchTerm: string): Observable> { - return this.courseManagementService.searchMembersForUserMentions(this.metisService.getCourse().id!, searchTerm); - } - - protected selectionToText(selected: ConversationUserDTO): string { - return `[user]${selected.name}(${selected.login})[/user]`; - } -} diff --git a/src/main/webapp/app/shared/markdown-editor/commands/fullscreen.command.ts b/src/main/webapp/app/shared/markdown-editor/commands/fullscreen.command.ts deleted file mode 100644 index a331a1c07479..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/commands/fullscreen.command.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { IconProp } from '@fortawesome/fontawesome-svg-core'; -import { faCompress } from '@fortawesome/free-solid-svg-icons'; -import { Command } from 'app/shared/markdown-editor/commands/command'; -import { enterFullscreen, exitFullscreen, isFullScreen } from 'app/shared/util/fullscreen.util'; - -/** - * Toggles fullscreen on button press. - * Uses the markdown editor wrapper including tabs as element for fullscreen. - * - * The command needs to check different browser implementations of the fullscreen mode so it is handled correctly. - */ -export class FullscreenCommand extends Command { - buttonIcon = faCompress as IconProp; - buttonTranslationString = 'artemisApp.markdownEditor.commands.fullscreen'; - - execute(): void { - if (isFullScreen()) { - exitFullscreen(); - } else { - const element = this.markdownWrapper.nativeElement; - enterFullscreen(element); - } - } -} diff --git a/src/main/webapp/app/shared/markdown-editor/commands/headingOne.command.ts b/src/main/webapp/app/shared/markdown-editor/commands/headingOne.command.ts deleted file mode 100644 index b37e8dce1c20..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/commands/headingOne.command.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { IconProp } from '@fortawesome/fontawesome-svg-core'; -import { faHeading } from '@fortawesome/free-solid-svg-icons'; -import { Command } from 'app/shared/markdown-editor/commands/command'; - -export class HeadingOneCommand extends Command { - buttonIcon = faHeading as IconProp; - buttonTranslationString = 'artemisApp.multipleChoiceQuestion.editor.headingOne'; - - /** - * @function execute - * @desc Create/remove heading one in markdown language - * 1. Check if the selected text includes (#) and/or ('Heading 1') - * 2. If included reduce the selected text by this elements and add replace the selected text by textToAdd - * 3. If not included add (#) before the selected text and insert them into the editor - * 4. Heading one in markdown language appears - */ - execute(): void { - let selectedText = this.getSelectedText(); - - if (selectedText.includes('#') && !selectedText.includes('Heading 1')) { - const textToAdd = selectedText.slice(2); - this.insertText(textToAdd); - } else if (selectedText.includes('#') && selectedText.includes('Heading 1')) { - const textToAdd = selectedText.slice(2, -9); - this.insertText(textToAdd); - } else { - const initText = 'Heading 1'; - const range = this.getRange(); - selectedText = `# ${selectedText || initText}`; - this.replace(range, selectedText); - this.focus(); - } - } -} diff --git a/src/main/webapp/app/shared/markdown-editor/commands/headingThree.command.ts b/src/main/webapp/app/shared/markdown-editor/commands/headingThree.command.ts deleted file mode 100644 index 0d9e3ac9185d..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/commands/headingThree.command.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { IconProp } from '@fortawesome/fontawesome-svg-core'; -import { faHeading } from '@fortawesome/free-solid-svg-icons'; -import { Command } from 'app/shared/markdown-editor/commands/command'; - -export class HeadingThreeCommand extends Command { - buttonIcon = faHeading as IconProp; - buttonTranslationString = 'artemisApp.multipleChoiceQuestion.editor.headingThree'; - - /** - * @function execute - * @desc Create/Remove heading three language - * 1. check if the selected text includes (###) and/or ('Heading 3') - * 2. If included reduce the selected text by this elements and add replace the selected text by textToAdd - * 3. If not included add (###) before the selected text and insert them into the editor - * 4. Heading three in markdown language appears - */ - execute(): void { - let selectedText = this.getSelectedText(); - - if (selectedText.includes('###') && !selectedText.includes('Heading 3')) { - const textToAdd = selectedText.slice(4); - this.insertText(textToAdd); - } else if (selectedText.includes('###') && selectedText.includes('Heading 3')) { - const textToAdd = selectedText.slice(4, -9); - this.insertText(textToAdd); - } else { - const initText = 'Heading 3'; - const range = this.getRange(); - selectedText = `### ${selectedText || initText}`; - this.replace(range, selectedText); - this.focus(); - } - } -} diff --git a/src/main/webapp/app/shared/markdown-editor/commands/headingTwo.command.ts b/src/main/webapp/app/shared/markdown-editor/commands/headingTwo.command.ts deleted file mode 100644 index 4f14cafd8e0b..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/commands/headingTwo.command.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { IconProp } from '@fortawesome/fontawesome-svg-core'; -import { faHeading } from '@fortawesome/free-solid-svg-icons'; -import { Command } from 'app/shared/markdown-editor/commands/command'; - -export class HeadingTwoCommand extends Command { - buttonIcon = faHeading as IconProp; - buttonTranslationString = 'artemisApp.multipleChoiceQuestion.editor.headingTwo'; - - /** - * @function execute - * @desc Create/Remove heading two language - * 1. Check if the selected text includes (##) and/or ('Heading 2') - * 2. If included reduce the selected text by this elements and add replace the selected text by textToAdd - * 3. If not included add (##) before the selected text and insert them into the editor - * 4. Heading two in markdown language appears - */ - execute(): void { - let selectedText = this.getSelectedText(); - - if (selectedText.includes('##') && !selectedText.includes('Heading 2')) { - const textToAdd = selectedText.slice(3); - this.insertText(textToAdd); - } else if (selectedText.includes('##') && selectedText.includes('Heading 2')) { - const textToAdd = selectedText.slice(3, -9); - this.insertText(textToAdd); - } else { - const initText = 'Heading 2'; - const range = this.getRange(); - selectedText = `## ${selectedText || initText}`; - this.replace(range, selectedText); - this.focus(); - } - } -} diff --git a/src/main/webapp/app/shared/markdown-editor/commands/interactiveSearchCommand.ts b/src/main/webapp/app/shared/markdown-editor/commands/interactiveSearchCommand.ts deleted file mode 100644 index a6024ed958b1..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/commands/interactiveSearchCommand.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { HttpResponse } from '@angular/common/http'; -import { MultiOptionCommand } from 'app/shared/markdown-editor/commands/multiOptionCommand'; -import { Observable } from 'rxjs'; -import { SelectWithSearchComponent } from 'app/shared/markdown-editor/select-with-search/select-with-search.component'; - -export interface SelectableItem { - name?: string; -} - -export abstract class InteractiveSearchCommand extends MultiOptionCommand { - private selectWithSearchComponent: SelectWithSearchComponent; - execute(): void { - this.aceEditor.execCommand(this.getAssociatedInputCharacter()); - } - - private searchPositionStart: { row: number; column: number } | undefined; - - setEditor(aceEditor: any) { - super.setEditor(aceEditor); - - this.aceEditor.commands.addCommand({ - name: this.getAssociatedInputCharacter(), - bindKey: { win: this.getAssociatedInputCharacter(), mac: this.getAssociatedInputCharacter() }, - exec: (editor: any) => { - if (this.searchPositionStart) { - return; - } - - const cursorPosition = this.getCursorPosition(); - const lineContent = editor.session.getLine(cursorPosition.row).substring(0, cursorPosition.column); - - editor.insert(this.getAssociatedInputCharacter()); - if (cursorPosition.column === 0 || lineContent.slice(-1).match(/\s/)) { - this.searchPositionStart = cursorPosition; - this.selectWithSearchComponent?.open(); - this.aceEditor.focus(); - } - }, - } as any); - } - - setSelectWithSearchComponent(component: SelectWithSearchComponent) { - this.selectWithSearchComponent = component; - } - - insertSelection(selected: SelectableItem | undefined) { - if (selected !== undefined) { - const cursorPosition = this.aceEditor.getCursorPosition() as { row: number; column: number }; - - this.aceEditor.session - .getDocument() - .removeInLine(cursorPosition.row, this.searchPositionStart?.row === cursorPosition.row ? this.searchPositionStart.column : 0, cursorPosition.column); - - this.searchPositionStart = undefined; - - this.insertText(this.selectionToText(selected)); - } - - this.searchPositionStart = undefined; - this.aceEditor.focus(); - } - - abstract performSearch(searchTerm: string): Observable>; - - protected abstract selectionToText(selected: any): string; - - protected abstract getAssociatedInputCharacter(): string; - - getCursorScreenPosition(): any { - const cursorPosition = super.getCursorPosition(); - return this.aceEditor.renderer.textToScreenCoordinates(cursorPosition.row, cursorPosition.column); - } - - updateSearchTerm() { - if (!this.searchPositionStart) { - return; - } - - const cursorPosition = this.aceEditor.getCursorPosition(); - const lineContent = this.aceEditor.session.getLine(cursorPosition.row); - - const lastAtIndex = lineContent - .substring(cursorPosition.row === this.searchPositionStart.row ? this.searchPositionStart.column : 0, cursorPosition.column + 1) - .lastIndexOf(this.getAssociatedInputCharacter()); - - if (lastAtIndex >= 0) { - const searchTerm = lineContent - .substring(0, cursorPosition.column + 1) - .split(this.getAssociatedInputCharacter()) - .pop(); - - this.selectWithSearchComponent.updateSearchTerm(searchTerm); - } else { - this.selectWithSearchComponent.close(); - } - } -} diff --git a/src/main/webapp/app/shared/markdown-editor/commands/italic.command.ts b/src/main/webapp/app/shared/markdown-editor/commands/italic.command.ts deleted file mode 100644 index 48d2ac7f4248..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/commands/italic.command.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { IconProp } from '@fortawesome/fontawesome-svg-core'; -import { faItalic } from '@fortawesome/free-solid-svg-icons'; -import { Command } from './command'; - -export class ItalicCommand extends Command { - buttonIcon = faItalic as IconProp; - buttonTranslationString = 'artemisApp.multipleChoiceQuestion.editor.italic'; - - /** - * @function execute - * @desc Make/Remove italic text - * 1. Check if the selected text includes (*) - * 2. If included reduce the selected text by this elements and replace the selected text by textToAdd - * 3. If not included add (*) before and after the selected text and insert them into the editor - * 4. Italic in markdown language appears - */ - execute(): void { - const selectedText = this.getSelectedText(); - let textToAdd = ''; - - if (selectedText.charAt(0) === '*' && selectedText.charAt(selectedText.length - 1) === '*') { - textToAdd = selectedText.slice(1, -1); - this.insertText(textToAdd); - } else { - const trimmedText = this.deleteWhiteSpace(selectedText); - textToAdd = `*${trimmedText}*`; - this.addRefinedText(selectedText, textToAdd); - } - } -} diff --git a/src/main/webapp/app/shared/markdown-editor/commands/katex.command.ts b/src/main/webapp/app/shared/markdown-editor/commands/katex.command.ts deleted file mode 100644 index 893ca18141e7..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/commands/katex.command.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { IconProp } from '@fortawesome/fontawesome-svg-core'; -import { faEquals } from '@fortawesome/free-solid-svg-icons'; -import { DomainTagCommand } from 'app/shared/markdown-editor/domainCommands/domainTag.command'; -import { addTextAtCursor } from 'app/shared/util/markdown.util'; - -/** - * Insert a katex compatible formula. - * Uses an e-function as the example. - */ -export class KatexCommand extends DomainTagCommand { - buttonIcon = faEquals as IconProp; - buttonTranslationString = 'artemisApp.markdownEditor.commands.katex'; - execute(): void { - const text = `${this.getOpeningIdentifier()}e^{\\frac{1}{4} y^2}${this.getClosingIdentifier()}`; - addTextAtCursor(text, this.aceEditor); - } - - getOpeningIdentifier(): string { - return '$$ '; - } - - getClosingIdentifier(): string { - return ' $$'; - } -} diff --git a/src/main/webapp/app/shared/markdown-editor/commands/link.command.ts b/src/main/webapp/app/shared/markdown-editor/commands/link.command.ts deleted file mode 100644 index ef16cdfe80b7..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/commands/link.command.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { IconProp } from '@fortawesome/fontawesome-svg-core'; -import { faLink } from '@fortawesome/free-solid-svg-icons'; -import { Command } from './command'; - -export class LinkCommand extends Command { - buttonIcon = faLink as IconProp; - buttonTranslationString = 'artemisApp.multipleChoiceQuestion.editor.link'; - - /** - * @function execute - * @desc Add/Remove a link in markdown language - * 1. check if the selected text includes ('[](http://)') - * 2. If included reduce the selected text by this elements and replace the selected text by textToAdd - * 3. If not included add ('[](http://)') at the cursor in the editor - * 4. Link in markdown language appears - */ - execute(): void { - let selectedText = this.getSelectedText(); - - if (selectedText.includes('[](http://)')) { - const textToAdd = selectedText.slice(11); - this.insertText(textToAdd); - } else { - const range = this.getRange(); - selectedText = `[](http://)`; - this.replace(range, selectedText); - this.focus(); - } - } -} diff --git a/src/main/webapp/app/shared/markdown-editor/commands/multiOptionCommand.ts b/src/main/webapp/app/shared/markdown-editor/commands/multiOptionCommand.ts deleted file mode 100644 index bc6bdfcb419f..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/commands/multiOptionCommand.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Command } from 'app/shared/markdown-editor/commands/command'; -import { ValueItem } from 'app/shared/markdown-editor/command-constants'; - -export abstract class MultiOptionCommand extends Command { - protected values: ValueItem[] = []; - - getValues() { - return this.values; - } - - setValues(values: ValueItem[]) { - this.values = values; - } -} diff --git a/src/main/webapp/app/shared/markdown-editor/commands/orderedListCommand.ts b/src/main/webapp/app/shared/markdown-editor/commands/orderedListCommand.ts deleted file mode 100644 index 015656f337ab..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/commands/orderedListCommand.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { IconProp } from '@fortawesome/fontawesome-svg-core'; -import { faListOl } from '@fortawesome/free-solid-svg-icons'; -import { Command } from './command'; - -export class OrderedListCommand extends Command { - buttonIcon = faListOl as IconProp; - buttonTranslationString = 'artemisApp.multipleChoiceQuestion.editor.orderedList'; - - /** - * @function execute - * @desc Use the Markdown language for creating/removing an ordered list - */ - execute(): void { - const extendedText = this.getExtendedSelectedText(); - this.handleManipulation(extendedText); - } - - /** - * Performs the necessary manipulations. - * @param extendedText the extended text - */ - handleManipulation(extendedText: string[]): void { - let manipulatedText = ''; - let position = 1; - - extendedText.forEach((line, index) => { - // Special case: Single empty line - if (line === '') { - if (extendedText.length === 1) { - manipulatedText = '1. '; - return; - } - } else { - // Manipulate the line, e.g. remove the number or add the number. - manipulatedText += this.manipulateLine(line, position); - position++; - } - - if (index !== extendedText.length - 1) { - manipulatedText += '\n'; - } - }); - this.replace(this.getRange(), manipulatedText); - } - - /** - * Manipulates a given line and adds or removes the numbers at the beginning. - * @param line to manipulate - * @param position of the line in the ordered list - * @return manipulated line - */ - manipulateLine(line: string, position: number): string { - const index = line.indexOf('.'); - - // Calc leading whitespaces - const whitespaces = line.search(/\S|$/); - - // There is a dot in this line - if (index !== -1) { - const elements = [line.slice(whitespaces, index), line.slice(index + 1)]; - - // Check if this is really a number - if (elements[0] !== '' && !isNaN(Number(elements[0]))) { - // Add whitespaces and remove first whitespace after the dot - return ' '.repeat(whitespaces) + elements[1].substring(1); - } - } - - // Add the position of the list element - return ' '.repeat(whitespaces) + `${position}. ${line.substring(whitespaces)}`; - } -} diff --git a/src/main/webapp/app/shared/markdown-editor/commands/reference.command.ts b/src/main/webapp/app/shared/markdown-editor/commands/reference.command.ts deleted file mode 100644 index 7da9c5bcd44a..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/commands/reference.command.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { IconProp } from '@fortawesome/fontawesome-svg-core'; -import { faQuoteLeft } from '@fortawesome/free-solid-svg-icons'; -import { Command } from './command'; - -export class ReferenceCommand extends Command { - buttonIcon = faQuoteLeft as IconProp; - buttonTranslationString = 'artemisApp.multipleChoiceQuestion.editor.quote'; - - /** - * @function execute - * @desc Add/Remove a reference in markdown language - * 1. Check if the selected text includes ('>') and/or ('Reference') - * 2. If included reduce the selected text by this elements and add replace the selected text by textToAdd - * 3. If not included add ('>') before the selected text and insert into editor - * 4. Reference in markdown appears - */ - execute(): void { - let selectedText = this.getSelectedText(); - - if (selectedText.includes('>') && !selectedText.includes('Reference')) { - const textToAdd = selectedText.slice(2); - this.insertText(textToAdd); - } else if (selectedText.includes('>') && selectedText.includes('Reference')) { - const textToAdd = selectedText.slice(2, -9); - this.insertText(textToAdd); - } else { - const range = this.getRange(); - const initText = 'Reference'; - selectedText = `> ${selectedText || initText}`; - this.replace(range, selectedText); - this.focus(); - } - } -} diff --git a/src/main/webapp/app/shared/markdown-editor/commands/underline.command.ts b/src/main/webapp/app/shared/markdown-editor/commands/underline.command.ts deleted file mode 100644 index b7a88c662249..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/commands/underline.command.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { IconProp } from '@fortawesome/fontawesome-svg-core'; -import { faUnderline } from '@fortawesome/free-solid-svg-icons'; -import { Command } from './command'; - -export class UnderlineCommand extends Command { - buttonIcon = faUnderline as IconProp; - buttonTranslationString = 'artemisApp.multipleChoiceQuestion.editor.underline'; - - /** - * @function execute - * @desc Add/Remove underline text - * 1. Check if the selected text includes () - * 2. If included (ins) reduce the selected text by these elements and replace the selected text by textToAdd - * 3. If not included, add () before and after the selected text and insert them into the editor - * 4. Underline in markdown language appears - */ - execute(): void { - const chosenText = this.getSelectedText(); - - if (chosenText.includes('')) { - const textToAdd = chosenText.slice(5, -6); - this.insertText(textToAdd); - } else { - const textToAdd = `${chosenText}`; - this.insertText(textToAdd); - } - } -} diff --git a/src/main/webapp/app/shared/markdown-editor/commands/unorderedListCommand.ts b/src/main/webapp/app/shared/markdown-editor/commands/unorderedListCommand.ts deleted file mode 100644 index 257ea83e77ee..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/commands/unorderedListCommand.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { IconProp } from '@fortawesome/fontawesome-svg-core'; -import { faListUl } from '@fortawesome/free-solid-svg-icons'; -import { Command } from './command'; - -export class UnorderedListCommand extends Command { - buttonIcon = faListUl as IconProp; - buttonTranslationString = 'artemisApp.multipleChoiceQuestion.editor.unorderedList'; - - /** - * @function execute - * @desc Use the Markdown language for creating an unordered list - */ - execute(): void { - const extendedText = this.getExtendedSelectedText(); - this.handleManipulation(extendedText); - } - - /** - * Performs the necessary manipulations. - * @param extendedText the extended text - */ - handleManipulation(extendedText: string[]): void { - let manipulatedText = ''; - - extendedText.forEach((line, index) => { - // Special case: Single empty line - if (line === '') { - if (extendedText.length === 1) { - manipulatedText = '- '; - return; - } - } else { - manipulatedText += this.manipulateLine(line); - } - - if (index !== extendedText.length - 1) { - manipulatedText += '\n'; - } - }); - this.replace(this.getRange(), manipulatedText); - } - - /** - * Manipulates a given line and adds or removes the - at the beginning. - * @param line to manipulate - * @return manipulated line - */ - manipulateLine(line: string): string { - const index = line.indexOf('-'); - - // Calc leading whitespaces - const whitespaces = line.search(/\S|$/); - - // There is - in this line - if (index !== -1) { - const elements = [line.slice(whitespaces, index), line.slice(index + 1)]; - - // Check if this is the first - - if (elements[0] === '' && elements[1].length >= 1 && elements[1].startsWith(' ')) { - // Add whitespaces and remove first whitespace after the dot - return ' '.repeat(whitespaces) + elements[1].substring(1); - } - } - - // Add the - - return ' '.repeat(whitespaces) + `- ${line.substring(whitespaces)}`; - } -} diff --git a/src/main/webapp/app/shared/markdown-editor/domainCommands/correctOptionCommand.ts b/src/main/webapp/app/shared/markdown-editor/domainCommands/correctOptionCommand.ts deleted file mode 100644 index 4a9764fc02a2..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/domainCommands/correctOptionCommand.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { addTextAtCursor } from 'app/shared/util/markdown.util'; -import { DomainTagCommand } from 'app/shared/markdown-editor/domainCommands/domainTag.command'; - -export class CorrectOptionCommand extends DomainTagCommand { - public static readonly IDENTIFIER = '[correct]'; - buttonTranslationString = 'artemisApp.multipleChoiceQuestion.editor.addCorrectAnswerOption'; - - /** - * @function execute - * @desc Add a new correct answer option to the text editor at the location of the cursor - */ - execute(): void { - const text = '\n' + this.getOpeningIdentifier() + ' Enter a correct answer option here'; - addTextAtCursor(text, this.aceEditor); - } - - /** - * @function getOpeningIdentifier - * @desc identify the start of the correct option - */ - getOpeningIdentifier(): string { - return CorrectOptionCommand.IDENTIFIER; - } - - /** - * @function getClosingIdentifier - * @desc identify the end of the correct option - */ - getClosingIdentifier(): string { - return '[/correct]'; - } -} diff --git a/src/main/webapp/app/shared/markdown-editor/domainCommands/credits.command.ts b/src/main/webapp/app/shared/markdown-editor/domainCommands/credits.command.ts deleted file mode 100644 index b7070571e62d..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/domainCommands/credits.command.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { DomainTagCommand } from 'app/shared/markdown-editor/domainCommands/domainTag.command'; -import { addTextAtCursor } from 'app/shared/util/markdown.util'; - -export class CreditsCommand extends DomainTagCommand { - public static readonly IDENTIFIER = '[credits]'; - public static readonly TEXT = '0'; - // ' Add points students should get for this instruction here'; - displayCommandButton = false; - - buttonTranslationString = 'artemisApp.assessmentInstructions.instructions.editor.addCredits'; - - /** - * @function execute - * @desc Add a credits for the corresponding instruction in the editor at the location of the cursor - */ - execute(): void { - const text = '\n' + this.getOpeningIdentifier() + CreditsCommand.TEXT; - addTextAtCursor(text, this.aceEditor); - } - - /** - * @function getOpeningIdentifier - * @desc identify the start of the credits - */ - getOpeningIdentifier(): string { - return CreditsCommand.IDENTIFIER; - } - - /** - * @function getClosingIdentifier - * @desc identify the end of the credits - */ - getClosingIdentifier(): string { - return '[/credits]'; - } -} diff --git a/src/main/webapp/app/shared/markdown-editor/domainCommands/domain-multi-option-list.command.ts b/src/main/webapp/app/shared/markdown-editor/domainCommands/domain-multi-option-list.command.ts deleted file mode 100644 index fb2ce14041c3..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/domainCommands/domain-multi-option-list.command.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { DomainMultiOptionCommand } from 'app/shared/markdown-editor/domainCommands/domainMultiOptionCommand'; -import { getStringSegmentPositions } from 'app/shared/util/global.utils'; -import { removeTextRange } from 'app/shared/util/markdown.util'; - -/** - * Allows the insertion of values within a comma separated list. - * Will e.g. remove duplicates and append new items to the list. - */ -export abstract class DomainMultiOptionListCommand extends DomainMultiOptionCommand { - protected abstract getValueMeta(): string; - - setEditor(aceEditor: any) { - super.setEditor(aceEditor); - - const autoCompleter = { - getCompletions: (editor: any, session: any, pos: any, prefix: any, callback: any) => { - callback( - null, - this.getValues().map(({ value, id }) => { - return { - caption: value, - value: id, - meta: this.getValueMeta(), - }; - }), - ); - }, - }; - - this.addCompleter(autoCompleter); - } - - /** - * Update the list of values in the multi option list. Makes sure to not add duplicates into the brackets. - * - * @function execute - * @desc insert selected value into text - */ - execute(valueToAdd: string): void { - const cursorPosition = this.getCursorPosition(); - const matchInTag = this.isCursorWithinTag(); - - this.clearSelection(); - - const newValuesList = this.generateValueList(matchInTag, valueToAdd, cursorPosition); - const newValuesStringified = `${this.getOpeningIdentifier()}${newValuesList.join(',')}${this.getClosingIdentifier()}`; - if (matchInTag) { - removeTextRange( - { col: matchInTag.matchStart, row: cursorPosition.row }, - { - col: matchInTag.matchEnd, - row: cursorPosition.row, - }, - this.aceEditor, - ); - } - this.insertText(newValuesStringified); - this.focus(); - } - - /** - * Given a match, the new value to add and the cursor position, determine and return the updated value list. - * a) The cursor is within the brackets -> add/replace value in list. - * b) The cursor is NOT within the brackets -> just paste in a new value. - * - * @param match - * @param valueToAdd - * @param cursorPosition - */ - private generateValueList = ( - match: { matchStart: number; matchEnd: number; innerTagContent: string } | null, - valueToAdd: string, - cursorPosition: { row: number; column: number }, - ): string[] => { - const { column } = cursorPosition; - // Check if the cursor is within the tag - if so, add the value to the list. - // Also don't add a value again that is already included. - if (match && !match.innerTagContent.includes(valueToAdd)) { - const currentValues = match.innerTagContent.split(','); - const stringPositions = getStringSegmentPositions(match.innerTagContent, ','); - const wordUnderCursor = stringPositions.find(({ start, end }) => column - 1 - match.matchStart > start && column - 1 - match.matchStart < end); - if (wordUnderCursor) { - // Case 1: Replace value. - return currentValues.map((val) => (val === wordUnderCursor.word ? valueToAdd : val)); - } else if (column >= match.matchEnd - 1) { - // Case 2: Add value on left side. - return [...currentValues, valueToAdd]; - } else if (column <= match.matchStart + 1) { - // Case 3: Add value on right side. - return [valueToAdd, ...currentValues]; - } else { - // Case 4: Fallback - replace current. - return [valueToAdd]; - } - } else if (match && match.innerTagContent.includes(valueToAdd)) { - // Case 5: The value is already included, do nothing. - return match.innerTagContent.split(','); - } else { - // Case 6: There is no content yet, just paste the current value in. - return [valueToAdd]; - } - }; -} diff --git a/src/main/webapp/app/shared/markdown-editor/domainCommands/domainCommand.ts b/src/main/webapp/app/shared/markdown-editor/domainCommands/domainCommand.ts deleted file mode 100644 index b5922fb35e3c..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/domainCommands/domainCommand.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { Command } from 'app/shared/markdown-editor/commands/command'; -import { escapeStringForUseInRegex } from 'app/shared/util/global.utils'; - -/** abstract class for all domainCommands - customized commands for Artemis specific use cases - * e.g. multiple choice questions, drag and drop questions - * Each domain command has its own logic and a unique identifier**/ -export abstract class DomainCommand extends Command { - abstract getOpeningIdentifier(): string; // e.g. [exp] - abstract getClosingIdentifier(): string; // e.g. [/exp] - displayCommandButton = true; - - /** - * Generate a regex that can be used to get the content alone or including the tags. - * index 0: Get content with tags around it. - * index 1: Get content without the tags. - */ - getTagRegex(flags = ''): RegExp { - const escapedOpeningIdentifier = escapeStringForUseInRegex(this.getOpeningIdentifier()), - escapedClosingIdentifier = escapeStringForUseInRegex(this.getClosingIdentifier()); - return new RegExp(`${escapedOpeningIdentifier}(.*)${escapedClosingIdentifier}`, flags); - } - - /** - * Checks if the cursor is placed within the identifiers of a domain command. - * Returns the content between the identifiers if there is match, otherwise returns null. - */ - isCursorWithinTag(): { matchStart: number; matchEnd: number; innerTagContent: string } | null { - const { row, column } = this.aceEditor.getCursorPosition(), - line = this.aceEditor.getSession().getLine(row), - regex = this.getTagRegex('g'); - - const indexes: Array<{ matchStart: number; matchEnd: number; innerTagContent: string }> = []; - - // A line can have multiple tags in it, so we need to check for multiple matches. - let match = regex.exec(line); - while (match != undefined) { - indexes.push({ matchStart: match.index, matchEnd: match.index + match[0].length, innerTagContent: match[1] }); - match = regex.exec(line); - } - const matchOnCursor = indexes.find(({ matchStart, matchEnd }) => column > matchStart && column <= matchEnd); - return matchOnCursor || null; - } - - /** - * Checks if there is a tag in the line of the cursor. - * Returns the content between the identifiers if there is match, otherwise returns null. - */ - isTagInRow(row: number): { matchStart: number; matchEnd: number; innerTagContent: string } | null { - const line = this.aceEditor.getSession().getLine(row), - regex = this.getTagRegex(); - - if (!line) { - return null; - } - - const match = line.match(regex); - if (!match) { - return null; - } - return { matchStart: match.index, matchEnd: match.index + match[0].length, innerTagContent: match[1] }; - } -} diff --git a/src/main/webapp/app/shared/markdown-editor/domainCommands/domainMultiOptionCommand.ts b/src/main/webapp/app/shared/markdown-editor/domainCommands/domainMultiOptionCommand.ts deleted file mode 100644 index 6918dd1adf80..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/domainCommands/domainMultiOptionCommand.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { DomainCommand } from 'app/shared/markdown-editor/domainCommands/domainCommand'; -import { ValueItem } from 'app/shared/markdown-editor/command-constants'; - -/** - * This domain command will be used as a dropdown in the markdown editor. - */ -export abstract class DomainMultiOptionCommand extends DomainCommand { - protected values: ValueItem[] = []; - setValues(values: ValueItem[]) { - this.values = values; - } - getValues() { - return this.values; - } -} diff --git a/src/main/webapp/app/shared/markdown-editor/domainCommands/domainTag.command.ts b/src/main/webapp/app/shared/markdown-editor/domainCommands/domainTag.command.ts deleted file mode 100644 index ff2c857c2558..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/domainCommands/domainTag.command.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { DomainCommand } from 'app/shared/markdown-editor/domainCommands/domainCommand'; - -/** - * This domain command will be used as a button in the markdown editor. - */ -export abstract class DomainTagCommand extends DomainCommand {} diff --git a/src/main/webapp/app/shared/markdown-editor/domainCommands/explanation.command.ts b/src/main/webapp/app/shared/markdown-editor/domainCommands/explanation.command.ts deleted file mode 100644 index e8e0ccbb7efd..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/domainCommands/explanation.command.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { addTextAtCursor } from 'app/shared/util/markdown.util'; -import { DomainTagCommand } from 'app/shared/markdown-editor/domainCommands/domainTag.command'; - -export const explanationCommandIdentifier = '[exp]'; - -export class ExplanationCommand extends DomainTagCommand { - public static readonly TEXT = ' Add an explanation here (only visible in feedback after quiz has ended)'; - - buttonTranslationString = 'artemisApp.multipleChoiceQuestion.editor.addExplanation'; - - /** - * @function execute - * @desc Add a new explanation to answer option or question title in the text editor at the location of the cursor - */ - execute(): void { - const text = '\n\t' + this.getOpeningIdentifier() + ExplanationCommand.TEXT; - addTextAtCursor(text, this.aceEditor); - } - - /** - * @function getOpeningIdentifier - * @desc identify the start of the explanation - */ - getOpeningIdentifier(): string { - return explanationCommandIdentifier; - } - - /** - * @function getClosingIdentifier - * @desc identify the end of the explanation - */ - getClosingIdentifier(): string { - return '[/exp]'; - } -} diff --git a/src/main/webapp/app/shared/markdown-editor/domainCommands/feedback.command.ts b/src/main/webapp/app/shared/markdown-editor/domainCommands/feedback.command.ts deleted file mode 100644 index 739b531dbff5..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/domainCommands/feedback.command.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { DomainTagCommand } from 'app/shared/markdown-editor/domainCommands/domainTag.command'; -import { addTextAtCursor } from 'app/shared/util/markdown.util'; - -export class FeedbackCommand extends DomainTagCommand { - public static readonly IDENTIFIER = '[feedback]'; - public static readonly TEXT = 'Add feedback for students here (visible for students)'; - - buttonTranslationString = 'artemisApp.assessmentInstructions.instructions.editor.addFeedback'; - displayCommandButton = false; - - /** - * @function execute - * @desc Add a new feedback for the corresponding instruction in the editor at the location of the cursor - */ - execute(): void { - const text = '\n' + this.getOpeningIdentifier() + FeedbackCommand.TEXT; - addTextAtCursor(text, this.aceEditor); - } - - /** - * @function getOpeningIdentifier - * @desc identify the start of the feedback - */ - getOpeningIdentifier(): string { - return FeedbackCommand.IDENTIFIER; - } - - /** - * @function getClosingIdentifier - * @desc identify the end of the feedback - */ - getClosingIdentifier(): string { - return '[/feedback]'; - } -} diff --git a/src/main/webapp/app/shared/markdown-editor/domainCommands/gradingCriterionCommand.ts b/src/main/webapp/app/shared/markdown-editor/domainCommands/gradingCriterionCommand.ts deleted file mode 100644 index 372842e7dbf7..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/domainCommands/gradingCriterionCommand.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { DomainTagCommand } from 'app/shared/markdown-editor/domainCommands/domainTag.command'; -import { addTextAtCursor } from 'app/shared/util/markdown.util'; -import { GradingInstructionCommand } from 'app/shared/markdown-editor/domainCommands/gradingInstruction.command'; - -export class GradingCriterionCommand extends DomainTagCommand { - public static readonly IDENTIFIER = '[criterion]'; - public static readonly TEXT = ' Add criteria title (only visible for tutors)'; - - buttonTranslationString = 'artemisApp.assessmentInstructions.instructions.editor.addCriterion'; - displayCommandButton = true; - gradingInstructionCommand = new GradingInstructionCommand(); - - /** - * @function execute - * @desc Add a new criterion for the corresponding exercise in the editor at the location of the cursor - */ - execute(): void { - const text = '\n' + this.getOpeningIdentifier() + GradingCriterionCommand.TEXT + '\n' + this.gradingInstructionCommand.instructionText(); - addTextAtCursor(text, this.aceEditor); - } - - /** - * @function getOpeningIdentifier - * @desc identify the start of the criterion - */ - getOpeningIdentifier(): string { - return GradingCriterionCommand.IDENTIFIER; - } - - /** - * @function getClosingIdentifier - * @desc identify the end of the criterion - */ - getClosingIdentifier(): string { - return '[/criterion]'; - } -} diff --git a/src/main/webapp/app/shared/markdown-editor/domainCommands/gradingInstruction.command.ts b/src/main/webapp/app/shared/markdown-editor/domainCommands/gradingInstruction.command.ts deleted file mode 100644 index 31d3179aacc3..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/domainCommands/gradingInstruction.command.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { DomainTagCommand } from 'app/shared/markdown-editor/domainCommands/domainTag.command'; -import { UsageCountCommand } from 'app/shared/markdown-editor/domainCommands/usageCount.command'; -import { FeedbackCommand } from 'app/shared/markdown-editor/domainCommands/feedback.command'; -import { CreditsCommand } from 'app/shared/markdown-editor/domainCommands/credits.command'; -import { addTextAtCursor } from 'app/shared/util/markdown.util'; -import { GradingScaleCommand } from 'app/shared/markdown-editor/domainCommands/gradingScaleCommand'; -import { InstructionDescriptionCommand } from 'app/shared/markdown-editor/domainCommands/instructionDescription.command'; - -export class GradingInstructionCommand extends DomainTagCommand { - creditsCommand = new CreditsCommand(); - gradingScaleCommand = new GradingScaleCommand(); - instructionCommand = new InstructionDescriptionCommand(); - feedbackCommand = new FeedbackCommand(); - usageCountCommand = new UsageCountCommand(); - - public static readonly IDENTIFIER = '[instruction]'; - - buttonTranslationString = 'artemisApp.assessmentInstructions.instructions.editor.addInstruction'; - - instructionText(): string { - return ( - this.getOpeningIdentifier() + - '\n' + - '\t' + - (this.creditsCommand.getOpeningIdentifier() + - CreditsCommand.TEXT + - '\n' + - '\t' + - this.gradingScaleCommand.getOpeningIdentifier() + - GradingScaleCommand.TEXT + - '\n' + - '\t' + - this.instructionCommand.getOpeningIdentifier() + - InstructionDescriptionCommand.TEXT + - '\n' + - '\t' + - this.feedbackCommand.getOpeningIdentifier() + - FeedbackCommand.TEXT + - '\n' + - '\t' + - this.usageCountCommand.getOpeningIdentifier() + - UsageCountCommand.TEXT) + - '\n' - ); - } - /** - * @function execute - * @desc Add a new grading instruction in the editor at the location of the cursor - */ - execute(): void { - const text = this.instructionText(); - - addTextAtCursor(text, this.aceEditor); - } - - /** - * @function getOpeningIdentifier - * @desc identify the start of the grading instruction - */ - getOpeningIdentifier(): string { - return GradingInstructionCommand.IDENTIFIER; - } - - /** - * @function getClosingIdentifier - * @desc identify the end of the grading instruction - */ - getClosingIdentifier(): string { - return '[/instruction]'; - } -} diff --git a/src/main/webapp/app/shared/markdown-editor/domainCommands/gradingScaleCommand.ts b/src/main/webapp/app/shared/markdown-editor/domainCommands/gradingScaleCommand.ts deleted file mode 100644 index df7834baf6b3..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/domainCommands/gradingScaleCommand.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { DomainTagCommand } from 'app/shared/markdown-editor/domainCommands/domainTag.command'; -import { addTextAtCursor } from 'app/shared/util/markdown.util'; - -export class GradingScaleCommand extends DomainTagCommand { - public static readonly IDENTIFIER = '[gradingScale]'; - public static readonly TEXT = 'Add instruction grading scale here (only visible for tutors)'; - displayCommandButton = false; - - buttonTranslationString = 'artemisApp.assessmentInstructions.instructions.editor.addGradingScale'; - - /** - * @function execute - * @desc Add a new gradingScale to the instruction in the editor at the location of the cursor - */ - execute(): void { - const text = '\n' + this.getOpeningIdentifier() + GradingScaleCommand.TEXT; - addTextAtCursor(text, this.aceEditor); - } - - /** - * @function getOpeningIdentifier - * @desc identify the start of the gradingScale - */ - getOpeningIdentifier(): string { - return GradingScaleCommand.IDENTIFIER; - } - - /** - * @function getClosingIdentifier - * @desc identify the end of the gradingScale - */ - getClosingIdentifier(): string { - return '[/gradingScale]'; - } -} diff --git a/src/main/webapp/app/shared/markdown-editor/domainCommands/hint.command.ts b/src/main/webapp/app/shared/markdown-editor/domainCommands/hint.command.ts deleted file mode 100644 index 380bd4efa65e..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/domainCommands/hint.command.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { addTextAtCursor } from 'app/shared/util/markdown.util'; -import { DomainTagCommand } from 'app/shared/markdown-editor/domainCommands/domainTag.command'; - -export const hintCommentIdentifier = '[hint]'; - -export class HintCommand extends DomainTagCommand { - public static readonly TEXT = ' Add a hint here (visible during the quiz via ?-Button)'; - - buttonTranslationString = 'artemisApp.multipleChoiceQuestion.editor.addHint'; - - /** - * @function execute - * @desc Add a new hint to the answer option or question title in the editor at the location of the cursor - */ - execute(): void { - const text = '\n\t' + this.getOpeningIdentifier() + HintCommand.TEXT; - addTextAtCursor(text, this.aceEditor); - } - - /** - * @function getOpeningIdentifier - * @desc identify the start of the hint - */ - getOpeningIdentifier(): string { - return hintCommentIdentifier; - } - - /** - * @function getClosingIdentifier - * @desc identify the end of the hint - */ - getClosingIdentifier(): string { - return '[/hint]'; - } -} diff --git a/src/main/webapp/app/shared/markdown-editor/domainCommands/incorrectOptionCommand.ts b/src/main/webapp/app/shared/markdown-editor/domainCommands/incorrectOptionCommand.ts deleted file mode 100644 index 02891d0d345f..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/domainCommands/incorrectOptionCommand.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { addTextAtCursor } from 'app/shared/util/markdown.util'; -import { DomainTagCommand } from 'app/shared/markdown-editor/domainCommands/domainTag.command'; - -export class IncorrectOptionCommand extends DomainTagCommand { - public static readonly IDENTIFIER = '[wrong]'; - buttonTranslationString = 'artemisApp.multipleChoiceQuestion.editor.addInCorrectAnswerOption'; - - /** - * @function execute - * @desc Add a new incorrect answer option to the text editor at the location of the cursor - */ - execute(): void { - const text = '\n' + this.getOpeningIdentifier() + ' Enter a wrong answer option here'; - addTextAtCursor(text, this.aceEditor); - } - - /** - * @function getOpeningIdentifier - * @desc identify the start of the correct option - */ - getOpeningIdentifier(): string { - return IncorrectOptionCommand.IDENTIFIER; - } - - /** - * @function getClosingIdentifier - * @desc identify the end of the correct option - */ - getClosingIdentifier(): string { - return '[/wrong]'; - } -} diff --git a/src/main/webapp/app/shared/markdown-editor/domainCommands/instructionDescription.command.ts b/src/main/webapp/app/shared/markdown-editor/domainCommands/instructionDescription.command.ts deleted file mode 100644 index 00e10e7d10b4..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/domainCommands/instructionDescription.command.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { DomainTagCommand } from 'app/shared/markdown-editor/domainCommands/domainTag.command'; -import { addTextAtCursor } from 'app/shared/util/markdown.util'; - -export class InstructionDescriptionCommand extends DomainTagCommand { - public static readonly IDENTIFIER = '[description]'; - public static readonly TEXT = 'Add grading instruction here (only visible for tutors)'; - displayCommandButton = false; - - buttonTranslationString = 'artemisApp.assessmentInstructions.instructions.editor.addInstruction'; - - /** - * @function execute - * @desc Add a new description of the instruction in the editor at the location of the cursor - */ - execute(): void { - const text = '\n' + this.getOpeningIdentifier() + InstructionDescriptionCommand.TEXT; - addTextAtCursor(text, this.aceEditor); - } - - /** - * @function getOpeningIdentifier - * @desc identify the start of the instruction description - */ - getOpeningIdentifier(): string { - return InstructionDescriptionCommand.IDENTIFIER; - } - - /** - * @function getClosingIdentifier - * @desc identify the end of the instruction description - */ - getClosingIdentifier(): string { - return '[/description]'; - } -} diff --git a/src/main/webapp/app/shared/markdown-editor/domainCommands/programming-exercise/task.command.ts b/src/main/webapp/app/shared/markdown-editor/domainCommands/programming-exercise/task.command.ts deleted file mode 100644 index 721785b5d4fd..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/domainCommands/programming-exercise/task.command.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { DomainTagCommand } from 'app/shared/markdown-editor/domainCommands/domainTag.command'; -import { escapeStringForUseInRegex } from 'app/shared/util/global.utils'; - -export class TaskCommand extends DomainTagCommand { - public static readonly IDENTIFIER = '[task]'; - public static readonly TASK_PLACE_HOLDER = 'Task Short Description'; - public static readonly TEST_CASE_PLACEHOLDER = 'testCaseName'; - - buttonTranslationString = 'artemisApp.programmingExercise.problemStatement.taskCommand'; - - setEditor(aceEditor: any) { - super.setEditor(aceEditor); - - const taskCommandCompleter = { - getCompletions: (editor: any, session: any, pos: any, prefix: any, callback: any) => { - callback(null, { caption: 'task', value: this.getTask(), meta: 'insert task' }); - }, - }; - this.addCompleter(taskCommandCompleter); - } - - /** - * The task structure is coupled to the value used in `ProgrammingExerciseTaskService` in the server and - * `ProgrammingExerciseTaskExtensionWrapper` in the client - * If you change the template, make sure to change it in all places! - */ - private getTask() { - return `${this.getOpeningIdentifier()}[${TaskCommand.TASK_PLACE_HOLDER}](${TaskCommand.TEST_CASE_PLACEHOLDER})`; - } - - public getTagRegex(flags = ''): RegExp { - const escapedOpeningIdentifier = escapeStringForUseInRegex(this.getOpeningIdentifier()); - return new RegExp(`${escapedOpeningIdentifier}(.*)`, flags); - } - - /** - * @function execute - * @desc add a new task. doesn't use the closing identifier for legacy reasons. - */ - execute(): void { - const currentLine = this.getCurrentLine(); - const startingNumber = currentLine.match(/(\d+)\..*/); - const thisLineNumber = startingNumber && startingNumber.length > 1 ? `\n${Number(startingNumber[1]) + 1}.` : '1.'; - const taskText = `${thisLineNumber} ${this.getTask()}`; - this.moveCursorToEndOfRow(); - this.insertText(taskText); - this.focus(); - } - - /** - * @function getOpeningIdentifier - * @desc identify the start of the task - */ - getOpeningIdentifier(): string { - return TaskCommand.IDENTIFIER; - } - - /** - * @function getClosingIdentifier - * @desc identify the end of the explanation - */ - getClosingIdentifier(): string { - return '[/task]'; - } -} diff --git a/src/main/webapp/app/shared/markdown-editor/domainCommands/programming-exercise/testCase.command.ts b/src/main/webapp/app/shared/markdown-editor/domainCommands/programming-exercise/testCase.command.ts deleted file mode 100644 index 6380c73c3234..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/domainCommands/programming-exercise/testCase.command.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { DomainMultiOptionListCommand } from 'app/shared/markdown-editor/domainCommands/domain-multi-option-list.command'; - -export class TestCaseCommand extends DomainMultiOptionListCommand { - buttonTranslationString = 'artemisApp.programmingExercise.problemStatement.testCaseCommand'; - - protected getValueMeta(): string { - return 'testCase'; - } - - /** - * @function getOpeningIdentifier - * @desc identify the start of the task - */ - getOpeningIdentifier(): string { - return '('; - } - - /** - * @function getClosingIdentifier - * @desc identify the end of the explanation - */ - getClosingIdentifier(): string { - return ')'; - } -} diff --git a/src/main/webapp/app/shared/markdown-editor/domainCommands/usageCount.command.ts b/src/main/webapp/app/shared/markdown-editor/domainCommands/usageCount.command.ts deleted file mode 100644 index 9fd37ec38da3..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/domainCommands/usageCount.command.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { DomainTagCommand } from 'app/shared/markdown-editor/domainCommands/domainTag.command'; -import { addTextAtCursor } from 'app/shared/util/markdown.util'; - -export class UsageCountCommand extends DomainTagCommand { - public static readonly IDENTIFIER = '[maxCountInScore]'; - public static readonly TEXT = '0'; - // 'Add how often the credits should be taken into consideration for this instruction: 0 -> the credits should be added as often as the instruction occurs' + - // ' x of type int -> credits will be added x times only, if instruction occurs more than x times it will not be counted and instead marked as subsequent fault'; - - buttonTranslationString = 'artemisApp.assessmentInstructions.instructions.editor.addCountUsage'; - displayCommandButton = false; - - /** - * @function execute - * @desc Add a new usage count for the corresponding instruction in the editor at the location of the cursor - */ - execute(): void { - const text = '\n' + this.getOpeningIdentifier() + UsageCountCommand.TEXT; - addTextAtCursor(text, this.aceEditor); - } - - /** - * @function getOpeningIdentifier - * @desc identify the start of the usage count - */ - getOpeningIdentifier(): string { - return UsageCountCommand.IDENTIFIER; - } - - /** - * @function getClosingIdentifier - * @desc identify the end of the usage count - */ - getClosingIdentifier(): string { - return '[/maxCountInScore]'; - } -} diff --git a/src/main/webapp/app/shared/markdown-editor/markdown-editor.component.html b/src/main/webapp/app/shared/markdown-editor/markdown-editor.component.html deleted file mode 100644 index 9b9c93c7be41..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/markdown-editor.component.html +++ /dev/null @@ -1,224 +0,0 @@ -
- -
-
diff --git a/src/main/webapp/app/shared/markdown-editor/markdown-editor.component.scss b/src/main/webapp/app/shared/markdown-editor/markdown-editor.component.scss deleted file mode 100644 index 935730fc5c81..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/markdown-editor.component.scss +++ /dev/null @@ -1,132 +0,0 @@ -.markdown-editor-wrapper { - display: flex; - flex-flow: column nowrap; - flex: 1 1 auto; - height: inherit; - color: var(--md-wrapper-color); - - // This is a fix for using the fullscreen mode on Safari. - &:-webkit-full-screen { - width: 100%; - height: 100%; - } - - .md-resize-icon { - color: var(--md-resize-icon-color); - } - - .tab-list { - background: var(--md-tab-list-bg); - } - .tab-content, - .tab-content > .active { - display: flex; - flex-flow: column nowrap; - flex: 1 1 auto; - height: 100%; - } - - .no-dropdown-indicator::after { - display: none; - } - - .markdown-editor { - display: flex; - flex-flow: column nowrap; - flex: 1 1 auto; - touch-action: none; - - // Fix for full screen mode, otherwise the color is black. - background-color: var(--md-editor-background); - - &__content { - height: 100% !important; - width: 100%; - flex: 1 1 auto; - overflow: auto; - } - - .file-input { - border-bottom: 1px dotted var(--md-file-input-border-color); - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - } - - &__file-input { - // cannot be set to 'display: false' or similar - // input value would not be sent - width: 0.1px; - height: 0.1px; - opacity: 0; - overflow: hidden; - position: absolute; - z-index: -1; - } - - &__file-input:focus, - &__file-label:focus { - outline: 1px dotted #000; - outline: -webkit-focus-ring-color auto 5px; - } - - &__file-label { - cursor: pointer; - width: 100%; - border: 1px solid var(--border-color); - border-top: none; - border-radius: 0 0.15em 0.15em 0; - - .upload-subtitle { - font-size: 12px; - color: var(--bs-secondary); - } - } - - &__markdown { - height: inherit; - overflow: auto; - padding: 1rem; - } - - &__commands { - &-default, - &-domain { - display: flex; - flex-flow: row wrap; - align-items: center; - } - - .mat-mdc-button { - height: fit-content; - } - } - - .rg-bottom { - align-self: center; - } - } -} - -.background-editor-high { - overflow: auto; -} - -.dropdown-menu { - max-height: 300px; - overflow-y: auto; - - li { - position: relative; - } -} - -.nested { - padding-left: 30px; -} -.nested-slide { - padding-left: 50px; -} - -.default-font-size { - font-size: var(--bs-body-font-size); -} diff --git a/src/main/webapp/app/shared/markdown-editor/markdown-editor.component.ts b/src/main/webapp/app/shared/markdown-editor/markdown-editor.component.ts deleted file mode 100644 index 77910b630a20..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/markdown-editor.component.ts +++ /dev/null @@ -1,519 +0,0 @@ -import { AfterViewInit, Component, ContentChild, ElementRef, EventEmitter, Input, Output, ViewChild, ViewEncapsulation } from '@angular/core'; -import { SafeHtml } from '@angular/platform-browser'; -// Note: this import has to be before the 'brace' imports -import { AceEditorComponent } from 'app/shared/markdown-editor/ace-editor/ace-editor.component'; -import 'brace/theme/chrome'; -import 'brace/mode/markdown'; -import 'brace/mode/latex'; -import 'brace/ext/language_tools'; -import { Interactable } from '@interactjs/core/Interactable'; -import interact from 'interactjs'; -import { ArtemisMarkdownService } from 'app/shared/markdown.service'; -import { FileUploaderService } from 'app/shared/http/file-uploader.service'; -import { AlertService, AlertType } from 'app/core/util/alert.service'; -import { ColorSelectorComponent } from 'app/shared/color-selector/color-selector.component'; -import { DomainTagCommand } from './domainCommands/domainTag.command'; -import { escapeStringForUseInRegex } from 'app/shared/util/global.utils'; -import { UnderlineCommand } from 'app/shared/markdown-editor/commands/underline.command'; -import { ColorPickerCommand } from 'app/shared/markdown-editor/commands/colorPicker.command'; -import { BoldCommand } from 'app/shared/markdown-editor/commands/bold.command'; -import { AttachmentCommand } from 'app/shared/markdown-editor/commands/attachmentCommand'; -import { ReferenceCommand } from 'app/shared/markdown-editor/commands/reference.command'; -import { DomainMultiOptionCommand } from 'app/shared/markdown-editor/domainCommands/domainMultiOptionCommand'; -import { FullscreenCommand } from 'app/shared/markdown-editor/commands/fullscreen.command'; -import { HeadingOneCommand } from 'app/shared/markdown-editor/commands/headingOne.command'; -import { Command } from 'app/shared/markdown-editor/commands/command'; -import { ItalicCommand } from 'app/shared/markdown-editor/commands/italic.command'; -import { OrderedListCommand } from 'app/shared/markdown-editor/commands/orderedListCommand'; -import { HeadingTwoCommand } from 'app/shared/markdown-editor/commands/headingTwo.command'; -import { LinkCommand } from 'app/shared/markdown-editor/commands/link.command'; -import { CodeCommand } from 'app/shared/markdown-editor/commands/code.command'; -import { DomainCommand } from 'app/shared/markdown-editor/domainCommands/domainCommand'; -import { UnorderedListCommand } from 'app/shared/markdown-editor/commands/unorderedListCommand'; -import { HeadingThreeCommand } from 'app/shared/markdown-editor/commands/headingThree.command'; -import { CodeBlockCommand } from 'app/shared/markdown-editor/commands/codeblock.command'; -import { faAngleDown, faAngleRight, faGripLines, faQuestionCircle } from '@fortawesome/free-solid-svg-icons'; -import { MultiOptionCommand } from 'app/shared/markdown-editor/commands/multiOptionCommand'; -import { v4 as uuid } from 'uuid'; -import { MultipleChoiceVisualQuestionComponent } from 'app/exercises/quiz/shared/questions/multiple-choice-question/multiple-choice-visual-question.component'; -import { ExerciseReferenceCommand } from 'app/shared/markdown-editor/commands/courseArtifactReferenceCommands/exerciseReferenceCommand'; -import { InteractiveSearchCommand } from 'app/shared/markdown-editor/commands/interactiveSearchCommand'; - -export enum MarkdownEditorHeight { - INLINE = 100, - SMALL = 300, - MEDIUM = 500, - LARGE = 1000, - EXTRA_LARGE = 1500, -} - -export enum EditorMode { - NONE = 'none', - LATEX = 'latex', -} - -const getAceMode = (mode: EditorMode) => { - switch (mode) { - case EditorMode.LATEX: - return 'ace/mode/latex'; - case EditorMode.NONE: - return null; - default: - return null; - } -}; - -@Component({ - selector: 'jhi-markdown-editor', - templateUrl: './markdown-editor.component.html', - styleUrls: ['./markdown-editor.component.scss'], - encapsulation: ViewEncapsulation.None, -}) -export class MarkdownEditorComponent implements AfterViewInit { - public MultiOptionCommand = MultiOptionCommand; - public DomainMultiOptionCommand = DomainMultiOptionCommand; - public DomainTagCommand = DomainTagCommand; - public InteractiveSearchCommand = InteractiveSearchCommand; - // This ref is used for entering the fullscreen mode. - @ViewChild('wrapper', { read: ElementRef, static: false }) wrapper: ElementRef; - @ViewChild('aceEditor', { static: false }) - aceEditorContainer: AceEditorComponent; - aceEditorOptions = { - autoUpdateContent: true, - mode: 'markdown', - }; - @ViewChild(ColorSelectorComponent, { static: false }) colorSelector: ColorSelectorComponent; - - /** {string} which is initially displayed in the editor generated and passed on from the parent component*/ - @Input() markdown?: string; - @Input() editorMode = EditorMode.NONE; - @Input() showLineNumbers = false; - @Output() markdownChange = new EventEmitter(); - @Output() html = new EventEmitter(); - - /** default colors for the markdown editor*/ - markdownColors = ['#ca2024', '#3ea119', '#ffffff', '#000000', '#fffa5c', '#0d3cc2', '#b05db8', '#d86b1f']; - selectedColor = '#000000'; - /** {array} containing all colorPickerCommands - * IMPORTANT: If you want to use the colorpicker you have to implement
- * because the class definitions are saved within that method*/ - @Input() colorCommands: Command[] = [new ColorPickerCommand()]; - - /** - * Use this array for commands that are not related to the markdown but to the editor (e.g. fullscreen mode). - * These elements will be displayed on the right side of the command bar. - */ - @Input() metaCommands: Command[] = [new FullscreenCommand()]; - - /** {array} containing all default commands accessible for the editor per default */ - @Input() defaultCommands: Command[] = [ - new BoldCommand(), - new ItalicCommand(), - new UnderlineCommand(), - new ReferenceCommand(), - new CodeCommand(), - new CodeBlockCommand(), - new LinkCommand(), - new AttachmentCommand(), - new OrderedListCommand(), - new UnorderedListCommand(), - ]; - - /** {array} containing all header commands accessible for the markdown editor per default*/ - @Input() headerCommands: Command[] = [new HeadingOneCommand(), new HeadingTwoCommand(), new HeadingThreeCommand()]; - - /** {domainCommands} containing all domain commands which need to be set by the parent component which contains the markdown editor */ - @Input() domainCommands: Array; - - /** {textWithDomainCommandsFound} emits an {array} of text lines with the corresponding domain command to the parent component which contains the markdown editor */ - @Output() textWithDomainCommandsFound = new EventEmitter<[string, DomainCommand | null][]>(); - - @Output() onPreviewSelect = new EventEmitter(); - @Output() onEditSelect = new EventEmitter(); - - /** {showPreviewButton} - * 1. true -> the preview of the editor is used - * 2. false -> the preview of the parent component is used, parent has to set this value to false with an input */ - @Input() showPreviewButton = true; - - /** - * true -> the markdown content will be rendered and shown, used when there are no special additions (e.g. text exercises) - * false -> the parent component adds its own preview content with id=preview. Used e.g. for programming exercises - */ - @Input() showDefaultPreview = true; - - @Input() showEditButton = true; - - @Input() showVisualModeButton = false; - - /** {previewTextAsHtml} text that is emitted to the parent component if the parent does not use any domain commands */ - previewTextAsHtml: SafeHtml | null; - - /** {previewMode} when editor is created the preview is set to false, since the edit mode is set active */ - previewMode = false; - - /** {visualMode} when editor is created the visual mode is set to false, since the edit mode is set active */ - visualMode = false; - @Input() - minHeightEditor = MarkdownEditorHeight.SMALL.valueOf(); - - @ContentChild(MultipleChoiceVisualQuestionComponent, { static: false }) visualChild: MultipleChoiceVisualQuestionComponent; - - /** Resizable constants **/ - @Input() - enableResize = true; - @Input() - resizableMaxHeight = MarkdownEditorHeight.LARGE; - @Input() - resizableMinHeight = MarkdownEditorHeight.SMALL; - interactResizable: Interactable; - - /** {enableFileUpload} - * whether to show the file upload field and enable the drag and drop functionality - * enabled by default - */ - @Input() - enableFileUpload = true; - - // Icons - faQuestionCircle = faQuestionCircle; - faGripLines = faGripLines; - faAngleRight = faAngleRight; - faAngleDown = faAngleDown; - - uniqueMarkdownEditorId: string; - - editorContentString: string; - - constructor( - private artemisMarkdown: ArtemisMarkdownService, - private fileUploaderService: FileUploaderService, - private alertService: AlertService, - ) { - this.uniqueMarkdownEditorId = 'markdown-editor-' + uuid(); - } - - /** opens the button for selecting the color */ - openColorSelector(event: MouseEvent) { - const marginTop = 35; - const height = 110; - this.colorSelector.openColorSelector(event, marginTop, height); - } - - /** selected text is changed into the chosen color */ - onSelectedColor(selectedColor: string) { - this.selectedColor = selectedColor; - this.colorCommands[0].execute(selectedColor); - } - - /** - * @function addCommand - * @param command - * @desc customize the user interface of the markdown editor by adding a command - */ - addCommand(command: Command) { - this.defaultCommands.push(command); - } - - /** - * @function removeCommand - * @param classRef Command - * @desc customize the user interface of the markdown editor by removing a command - */ - removeCommand(classRef: typeof Command) { - setTimeout(() => (this.defaultCommands = this.defaultCommands.filter((element) => !(element instanceof classRef)))); - } - - isTypeOfExerciseReferenceCommand(commandToCheck: MultiOptionCommand) { - return commandToCheck instanceof ExerciseReferenceCommand; - } - - ngAfterViewInit(): void { - // Commands may want to add custom completers - remove standard completers of the ace editor. - this.aceEditorContainer.getEditor().setOptions({ - enableBasicAutocompletion: true, - enableLiveAutocompletion: true, - }); - this.aceEditorContainer.getEditor().completers = []; - - if (this.domainCommands == undefined || this.domainCommands.length === 0) { - [...this.defaultCommands, ...this.colorCommands, ...(this.headerCommands || []), ...this.metaCommands].forEach((command) => { - command.setEditor(this.aceEditorContainer.getEditor()); - command.setMarkdownWrapper(this.wrapper); - }); - } else { - [...this.defaultCommands, ...this.domainCommands, ...this.colorCommands, ...(this.headerCommands || []), ...this.metaCommands].forEach((command) => { - command.setEditor(this.aceEditorContainer.getEditor()); - command.setMarkdownWrapper(this.wrapper); - }); - } - this.setupMarkdownEditor(); - const selectedAceMode = getAceMode(this.editorMode); - if (selectedAceMode) { - this.aceEditorContainer.getEditor().getSession().setMode(selectedAceMode); - } - - if (this.enableResize) { - this.setupResizable(); - } - } - - /** - * @function setupQuestionEditor - * @desc Initializes the ace editor - */ - setupMarkdownEditor(): void { - this.aceEditorContainer.getEditor().renderer.setShowGutter(this.showLineNumbers); - this.aceEditorContainer.getEditor().renderer.setPadding(10); - this.aceEditorContainer.getEditor().renderer.setScrollMargin(8, 8); - this.aceEditorContainer.getEditor().setHighlightActiveLine(false); - this.aceEditorContainer.getEditor().setShowPrintMargin(false); - this.aceEditorContainer.getEditor().clearSelection(); - this.aceEditorContainer.getEditor().setAutoScrollEditorIntoView(true); - this.aceEditorContainer.getEditor().setOptions({ wrap: true }); - } - - /** - * @function setupResizable - * @desc Sets up resizable to enable resizing for the user - */ - setupResizable(): void { - // Use a unique, random ID to select the editor - // This is required to select the correct one in case multiple editors are used at the same time - const selector = '#' + this.uniqueMarkdownEditorId; - - // unregister previously set event listeners for class elements - interact(selector).unset(); - - this.interactResizable = interact(selector) - .resizable({ - // Enable resize from top edge; triggered by class rg-top - edges: { left: false, right: false, bottom: '.rg-bottom', top: false }, - // Set min and max height - modifiers: [ - interact.modifiers!.restrictSize({ - min: { width: 0, height: this.resizableMinHeight }, - max: { width: 2000, height: this.resizableMaxHeight }, - }), - ], - inertia: true, - }) - .on('resizestart', function (event: any) { - event.target.classList.add('card-resizable'); - }) - .on('resizeend', (event: any) => { - event.target.classList.remove('card-resizable'); - }) - .on('resizemove', (event: any) => { - const target = event.target; - // Update element height - target.style.height = event.rect.height + 'px'; - this.aceEditorContainer.getEditor().resize(); - }); - } - - /** - * Parses markdown to generate a preview if the standard preview is used and/or searches for domain command identifiers. - * Will emit events for both the generated preview and domain commands. - * - */ - parse(): void { - if (this.showDefaultPreview) { - this.previewTextAsHtml = this.artemisMarkdown.safeHtmlForMarkdown(this.markdown); - this.html.emit(this.previewTextAsHtml); - } - if (this.domainCommands && this.domainCommands.length && this.markdown) { - /** create array with domain command identifier */ - const domainCommandIdentifiersToParse = this.domainCommands.map((command) => command.getOpeningIdentifier()); - /** create empty array which - * will contain the splitted text with the corresponding domainCommandIdentifier which - * will be emitted to the parent component */ - const commandTextsMappedToCommandIdentifiers: [string, DomainCommand | null][] = []; - /** create a remainingMarkdownText of the markdown text to loop through it and find the domainCommandIdentifier */ - let remainingMarkdownText = this.markdown.slice(0); - - /** create string with the identifiers to use for RegEx by deleting the [] of the domainCommandIdentifiers */ - const commandIdentifiersString = domainCommandIdentifiersToParse - .map((tag) => tag.replace('[', '').replace(']', '')) - .map(escapeStringForUseInRegex) - .join('|'); - - /** create a new regex expression which searches for the domainCommands identifiers - * (?= If a command is found, add the command identifier to the result of the split - * \\[ look for the character '[' to determine the beginning of the command identifier - * (${commandIdentifiersString}) look if after the '[' one of the element of commandIdentifiersString is contained - * \\] look for the character ']' to determine the end of the command identifier - * ) close the bracket - * g: search in the whole string - * i: case insensitive, neglecting capital letters - * m: match the regex over multiple lines*/ - const regex = new RegExp(`(?=\\[(${commandIdentifiersString})\\])`, 'gmi'); - - /** iterating loop as long as the remainingMarkdownText of the markdown text exists and split the remainingMarkdownText when a domainCommand identifier is found */ - while (remainingMarkdownText.length) { - /** As soon as an identifier is with regEx the remainingMarkdownText of the markdown text is split and saved into {array} textWithCommandIdentifier - * split: saves its values into an {array} - * limit 1: indicated that as soon as an identifier is found remainingMarkdownText is split */ - const [textWithCommandIdentifier] = remainingMarkdownText.split(regex, 1); - /** substring: reduces the {string} by the length in the brackets - * Split the remainingMarkdownText by the length of {array} textWithCommandIdentifier to get the remaining array - * and save it into remainingMarkdownText to start the loop again and search for further domainCommandIdentifiers - * when remainingMarkdownText is empty the while loop will terminate*/ - remainingMarkdownText = remainingMarkdownText.substring(textWithCommandIdentifier.length); - /** call the parseLineForDomainCommand for each extracted textWithCommandIdentifier - * trim: reduced the whitespacing linebreaks */ - const commandTextWithCommandIdentifier = this.parseLineForDomainCommand(textWithCommandIdentifier.trim()); - /** push the commandTextWithCommandIdentifier into the commandTextsMappedToCommandIdentifiers*/ - commandTextsMappedToCommandIdentifiers.push(commandTextWithCommandIdentifier); - } - /** emit the {array} commandTextsMappedToCommandIdentifiers to the client*/ - this.textWithDomainCommandsFound.emit(commandTextsMappedToCommandIdentifiers); - } - } - - /** - * @function parseLineForDomainCommand - * @desc Couple each text with the domainCommandIdentifier to emit that to the parent component for the value assignment - * 1. Check which domainCommand identifier is contained within the text - * 2. Remove the domainCommand identifier from the text - * 3. Create an array with first element text and second element the domainCommand identifier - * @param text {string} from the parse function - * @return array of the text with the domainCommand identifier - */ - private parseLineForDomainCommand = (text: string): [string, DomainCommand | null] => { - for (const domainCommand of this.domainCommands) { - const possibleOpeningCommandIdentifier = [ - domainCommand.getOpeningIdentifier(), - domainCommand.getOpeningIdentifier().toLowerCase(), - domainCommand.getOpeningIdentifier().toUpperCase(), - ]; - if (possibleOpeningCommandIdentifier.some((identifier) => text.indexOf(identifier) !== -1)) { - // TODO when closingIdentifiers are used write a method to extract them from the text - const trimmedLineWithoutIdentifier = possibleOpeningCommandIdentifier.reduce((line, identifier) => line.replace(identifier, ''), text).trim(); - return [trimmedLineWithoutIdentifier, domainCommand]; - } - } - return [text.trim(), null]; - }; - - /** - * @function togglePreview - * @desc Toggle the preview in the template and parse the text - */ - changeNavigation(event: any): void { - this.previewMode = event.nextId === 'editor_preview'; - this.visualMode = event.nextId === 'editor_visual'; - - if (this.previewMode) { - this.onPreviewSelect.emit(); - } else { - this.onEditSelect.emit(); - } - - if (event.activeId === 'editor_visual' && this.visualChild) { - this.markdown = this.visualChild.parseQuestion(); - - if (this.previewMode) { - this.parse(); - } - } - - // The text must only be parsed when the active tab before event was edit, otherwise the text can't have changed. - if (event.activeId === 'editor_edit') { - this.parse(); - } - } - - /** - * @function onFileUpload - * @desc handle file upload for input - * @param event - */ - onFileUpload(event: any): void { - if (event.target.files.length >= 1) { - this.embedFiles(Array.from(event.target.files)); - } - } - - /** - * @function onFileDrop - * @desc handle drop of files - * @param {DragEvent} event - */ - onFileDrop(event: DragEvent): void { - event.preventDefault(); - if (event.dataTransfer?.items) { - // Use DataTransferItemList interface to access the file(s) - const files = new Array(); - for (let i = 0; i < event.dataTransfer.items.length; i++) { - // If dropped items aren't files, reject them - if (event.dataTransfer.items[i].kind === 'file') { - const file = event.dataTransfer.items[i].getAsFile(); - if (file) { - files.push(file); - } - } - } - this.embedFiles(files); - } else if (event.dataTransfer?.files) { - // Use DataTransfer interface to access the file(s) - this.embedFiles(Array.from(event.dataTransfer.files)); - } - } - - /** - * @function onFilePaste - * @desc handle paste of files - * @param {ClipboardEvent} event - */ - onFilePaste(event: ClipboardEvent): void { - if (event.clipboardData?.items) { - const images = new Array(); - for (let i = 0; i < event.clipboardData.items.length; i++) { - if (event.clipboardData.items[i].kind === 'file') { - const file = event.clipboardData.items[i].getAsFile(); - if (file) { - images.push(file); - } - } - } - this.embedFiles(images); - } - } - - /** - * @function embedFiles - * @desc generate and embed markdown code for files - * @param {FileList} files - */ - embedFiles(files: File[]): void { - const aceEditor = this.aceEditorContainer.getEditor(); - files.forEach((file: File) => { - this.fileUploaderService.uploadMarkdownFile(file).then( - (res) => { - const extension = file.name.split('.').pop()!.toLocaleLowerCase(); - - let textToAdd = `[${file.name}](${res.path})\n`; - if (extension !== 'pdf') { - // Show file as embedded image - textToAdd = '!' + textToAdd; - } - - aceEditor.insert(textToAdd); - }, - (error: Error) => { - this.alertService.addAlert({ - type: AlertType.DANGER, - message: error.message, - disableTranslation: true, - }); - }, - ); - }); - } - - markdownTextChange(value: any) { - this.markdown = value; - this.markdownChange.emit(value as string); - } -} diff --git a/src/main/webapp/app/shared/markdown-editor/markdown-editor.module.ts b/src/main/webapp/app/shared/markdown-editor/markdown-editor.module.ts index cb04a2cbab90..198dad328789 100644 --- a/src/main/webapp/app/shared/markdown-editor/markdown-editor.module.ts +++ b/src/main/webapp/app/shared/markdown-editor/markdown-editor.module.ts @@ -1,19 +1,16 @@ -import { MarkdownEditorComponent } from './markdown-editor.component'; import { NgModule } from '@angular/core'; -import { AceEditorModule } from 'app/shared/markdown-editor/ace-editor/ace-editor.module'; import { FormsModule } from '@angular/forms'; import { ArtemisColorSelectorModule } from 'app/shared/color-selector/color-selector.module'; import { ArtemisSharedModule } from 'app/shared/shared.module'; import { MatMenuModule } from '@angular/material/menu'; import { MatButtonModule } from '@angular/material/button'; -import { SelectWithSearchComponent } from './select-with-search/select-with-search.component'; import { MarkdownEditorMonacoComponent } from 'app/shared/markdown-editor/monaco/markdown-editor-monaco.component'; import { MonacoEditorModule } from 'app/shared/monaco-editor/monaco-editor.module'; import { DragDropModule } from '@angular/cdk/drag-drop'; @NgModule({ - imports: [ArtemisSharedModule, AceEditorModule, MonacoEditorModule, FormsModule, ArtemisColorSelectorModule, MatMenuModule, MatButtonModule, DragDropModule], - declarations: [MarkdownEditorComponent, MarkdownEditorMonacoComponent, SelectWithSearchComponent], - exports: [MarkdownEditorComponent, MarkdownEditorMonacoComponent], + imports: [ArtemisSharedModule, MonacoEditorModule, FormsModule, ArtemisColorSelectorModule, MatMenuModule, MatButtonModule, DragDropModule], + declarations: [MarkdownEditorMonacoComponent], + exports: [MarkdownEditorMonacoComponent], }) export class ArtemisMarkdownEditorModule {} diff --git a/src/main/webapp/app/shared/markdown-editor/monaco/markdown-editor-monaco.component.scss b/src/main/webapp/app/shared/markdown-editor/monaco/markdown-editor-monaco.component.scss index 77fb21831f07..4b06f7427978 100644 --- a/src/main/webapp/app/shared/markdown-editor/monaco/markdown-editor-monaco.component.scss +++ b/src/main/webapp/app/shared/markdown-editor/monaco/markdown-editor-monaco.component.scss @@ -1,8 +1,32 @@ .markdown-editor-wrapper { + display: flex; + flex-flow: column nowrap; + flex: 1 1 auto; + height: inherit; + color: var(--md-wrapper-color); background-color: var(--md-editor-background); + + // This is a fix for using the fullscreen mode on Safari. + &:-webkit-full-screen { + width: 100%; + height: 100%; + } + .markdown-editor { border: 1px solid var(--border-color); } + + .md-resize-icon { + color: var(--md-resize-icon-color); + } + + .tab-list { + background: var(--md-tab-list-bg); + } + + .no-dropdown-indicator::after { + display: none; + } } .resize-placeholder { @@ -32,3 +56,20 @@ color: var(--bs-secondary); } } + +.background-editor-high { + overflow: auto; +} + +.dropdown-menu { + max-height: 300px; + overflow-y: auto; + + li { + position: relative; + } +} + +.default-font-size { + font-size: var(--bs-body-font-size); +} diff --git a/src/main/webapp/app/shared/markdown-editor/monaco/markdown-editor-monaco.component.ts b/src/main/webapp/app/shared/markdown-editor/monaco/markdown-editor-monaco.component.ts index a3f9c027a8db..7f9fe5ee75d4 100644 --- a/src/main/webapp/app/shared/markdown-editor/monaco/markdown-editor-monaco.component.ts +++ b/src/main/webapp/app/shared/markdown-editor/monaco/markdown-editor-monaco.component.ts @@ -13,7 +13,6 @@ import { computed, } from '@angular/core'; import { MonacoEditorComponent } from 'app/shared/monaco-editor/monaco-editor.component'; -import { MarkdownEditorHeight } from 'app/shared/markdown-editor/markdown-editor.component'; import { NgbNavChangeEvent } from '@ng-bootstrap/ng-bootstrap'; import { MonacoEditorAction } from 'app/shared/monaco-editor/model/actions/monaco-editor-action.model'; import { MonacoBoldAction } from 'app/shared/monaco-editor/model/actions/monaco-bold.action'; @@ -47,6 +46,14 @@ import { ArtemisMarkdownService } from 'app/shared/markdown.service'; import { parseMarkdownForDomainActions } from 'app/shared/markdown-editor/monaco/markdown-editor-parsing.helper'; import { COMMUNICATION_MARKDOWN_EDITOR_OPTIONS, DEFAULT_MARKDOWN_EDITOR_OPTIONS } from 'app/shared/monaco-editor/monaco-editor-option.helper'; +export enum MarkdownEditorHeight { + INLINE = 100, + SMALL = 300, + MEDIUM = 500, + LARGE = 1000, + EXTRA_LARGE = 1500, +} + interface MarkdownActionsByGroup { standard: MonacoEditorAction[]; header: MonacoHeadingAction[]; @@ -76,7 +83,7 @@ const BORDER_HEIGHT_OFFSET = 2; @Component({ selector: 'jhi-markdown-editor-monaco', templateUrl: './markdown-editor-monaco.component.html', - styleUrls: ['./markdown-editor-monaco.component.scss', '../markdown-editor.component.scss'], + styleUrls: ['./markdown-editor-monaco.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) export class MarkdownEditorMonacoComponent implements AfterContentInit, AfterViewInit, OnDestroy { diff --git a/src/main/webapp/app/shared/markdown-editor/select-with-search/select-with-search.component.html b/src/main/webapp/app/shared/markdown-editor/select-with-search/select-with-search.component.html deleted file mode 100644 index f35d33d0558f..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/select-with-search/select-with-search.component.html +++ /dev/null @@ -1,11 +0,0 @@ -
- -
-
- @for (value of values; track value) { - - } -
-
diff --git a/src/main/webapp/app/shared/markdown-editor/select-with-search/select-with-search.component.scss b/src/main/webapp/app/shared/markdown-editor/select-with-search/select-with-search.component.scss deleted file mode 100644 index 1b28bd67c7fd..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/select-with-search/select-with-search.component.scss +++ /dev/null @@ -1,9 +0,0 @@ -.invisible-anchor { - position: absolute; - width: 0; - height: 0; -} - -#anchor::after { - display: none; -} diff --git a/src/main/webapp/app/shared/markdown-editor/select-with-search/select-with-search.component.ts b/src/main/webapp/app/shared/markdown-editor/select-with-search/select-with-search.component.ts deleted file mode 100644 index af415a2ab1ec..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/select-with-search/select-with-search.component.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { ChangeDetectorRef, Component, ElementRef, Input, OnChanges, OnDestroy, OnInit, SimpleChanges, ViewChild } from '@angular/core'; -import { InteractiveSearchCommand, SelectableItem } from 'app/shared/markdown-editor/commands/interactiveSearchCommand'; -import { AlertService } from 'app/core/util/alert.service'; -import { onError } from 'app/shared/util/global.utils'; -import { Subject, debounce, distinctUntilChanged, switchMap, takeUntil, timer } from 'rxjs'; -import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; -import { NgbDropdown, NgbDropdownConfig } from '@ng-bootstrap/ng-bootstrap'; - -interface SearchQuery { - searchTerm: string; - noDebounce: boolean; -} - -@Component({ - selector: 'jhi-select-with-search', - templateUrl: './select-with-search.component.html', - styleUrls: ['./select-with-search.component.scss'], - providers: [NgbDropdownConfig], -}) -export class SelectWithSearchComponent implements OnInit, OnChanges, OnDestroy { - @Input() command: InteractiveSearchCommand; - @Input() editorContentString: string; - - @ViewChild(NgbDropdown) dropdown: NgbDropdown; - @ViewChild('dropdown') dropdownRef: ElementRef; - - private ngUnsubscribe = new Subject(); - private readonly search$ = new Subject(); - - values: SelectableItem[] = []; - selectedValue: SelectableItem | undefined; - offsetX: string; - offsetY: string; - - constructor( - private readonly alertService: AlertService, - private readonly cdr: ChangeDetectorRef, - ) {} - - ngOnInit(): void { - this.command.setSelectWithSearchComponent(this); - - this.search$ - .pipe( - debounce((searchQuery) => { - return timer(searchQuery.noDebounce ? 0 : 200); - }), - distinctUntilChanged((prev, curr) => { - return prev === curr; - }), - switchMap((searchQuery) => this.command.performSearch(searchQuery.searchTerm)), - takeUntil(this.ngUnsubscribe), - ) - .subscribe({ - next: (res: HttpResponse) => { - this.values = res.body!; - this.cdr.detectChanges(); - }, - error: (errorResponse: HttpErrorResponse) => { - onError(this.alertService, errorResponse); - }, - }); - } - - ngOnChanges(changes: SimpleChanges): void { - if (changes.editorContentString) { - this.command.updateSearchTerm(); - } - } - - ngOnDestroy(): void { - this.ngUnsubscribe.next(); - this.ngUnsubscribe.complete(); - } - - open() { - this.dropdown.open(); - } - - close() { - this.dropdown.close(); - } - - updateSearchTerm(searchInput: string | undefined, noDebounce = false) { - const searchTerm = searchInput?.trim().toLowerCase() ?? ''; - this.search$.next({ searchTerm, noDebounce }); - } - - handleMenuOpen() { - const cursorPosition = this.command.getCursorScreenPosition(); - const dropdownPosition = this.dropdownRef.nativeElement.getBoundingClientRect(); - this.offsetX = cursorPosition.pageX - dropdownPosition.left + 'px'; - this.offsetY = cursorPosition.pageY - dropdownPosition.top + 'px'; - this.updateSearchTerm('', true); - this.cdr.detectChanges(); - } - - handleMenuClosed() { - this.command.insertSelection(this.selectedValue); - this.selectedValue = undefined; - } - - setSelection(value: any) { - this.selectedValue = value; - this.dropdown.close(); - } - - handleToggle() { - if (this.dropdown.isOpen()) { - this.close(); - } else { - this.command.execute(); - } - } -} diff --git a/src/main/webapp/app/shared/markdown-editor/value-item.model.ts b/src/main/webapp/app/shared/markdown-editor/value-item.model.ts new file mode 100644 index 000000000000..dbeacebf118a --- /dev/null +++ b/src/main/webapp/app/shared/markdown-editor/value-item.model.ts @@ -0,0 +1,7 @@ +export type ValueItem = { + id: string; + value: string; + type?: string; + elements?: ValueItem[]; + attachmentUnits?: ValueItem[]; +}; diff --git a/src/main/webapp/app/shared/markdown.module.ts b/src/main/webapp/app/shared/markdown.module.ts index 00df892f1be2..1d10d89b3045 100644 --- a/src/main/webapp/app/shared/markdown.module.ts +++ b/src/main/webapp/app/shared/markdown.module.ts @@ -1,13 +1,12 @@ import { NgModule } from '@angular/core'; import { ArtemisMarkdownService } from 'app/shared/markdown.service'; import { ArtemisMarkdownEditorModule } from 'app/shared/markdown-editor/markdown-editor.module'; -import { MarkdownEditorComponent } from 'app/shared/markdown-editor/markdown-editor.component'; import { HtmlForMarkdownPipe } from 'app/shared/pipes/html-for-markdown.pipe'; @NgModule({ imports: [ArtemisMarkdownEditorModule], declarations: [HtmlForMarkdownPipe], providers: [ArtemisMarkdownService], - exports: [MarkdownEditorComponent, HtmlForMarkdownPipe], + exports: [HtmlForMarkdownPipe], }) export class ArtemisMarkdownModule {} diff --git a/src/main/webapp/app/shared/metis/posting-create-edit.directive.ts b/src/main/webapp/app/shared/metis/posting-create-edit.directive.ts index 1c8abc2d4b55..5d327cbe390e 100644 --- a/src/main/webapp/app/shared/metis/posting-create-edit.directive.ts +++ b/src/main/webapp/app/shared/metis/posting-create-edit.directive.ts @@ -4,7 +4,8 @@ import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; import { Posting } from 'app/entities/metis/posting.model'; import { MetisService } from 'app/shared/metis/metis.service'; import { PostingEditType } from 'app/shared/metis/metis.util'; -import { MarkdownEditorHeight } from 'app/shared/markdown-editor/markdown-editor.component'; + +import { MarkdownEditorHeight } from 'app/shared/markdown-editor/monaco/markdown-editor-monaco.component'; // Note: this number should be the same as in Posting.java const MAX_CONTENT_LENGTH = 5000; diff --git a/src/main/webapp/app/shared/metis/posting-markdown-editor/posting-markdown-editor.component.ts b/src/main/webapp/app/shared/metis/posting-markdown-editor/posting-markdown-editor.component.ts index decd36d41b61..567846a81e3b 100644 --- a/src/main/webapp/app/shared/metis/posting-markdown-editor/posting-markdown-editor.component.ts +++ b/src/main/webapp/app/shared/metis/posting-markdown-editor/posting-markdown-editor.component.ts @@ -13,7 +13,6 @@ import { forwardRef, } from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; -import { MarkdownEditorHeight } from 'app/shared/markdown-editor/markdown-editor.component'; import { MetisService } from 'app/shared/metis/metis.service'; import { LectureService } from 'app/lecture/lecture.service'; import { CourseManagementService } from 'app/course/manage/course-management.service'; @@ -26,7 +25,7 @@ import { MonacoUnderlineAction } from 'app/shared/monaco-editor/model/actions/mo import { MonacoQuoteAction } from 'app/shared/monaco-editor/model/actions/monaco-quote.action'; import { MonacoCodeAction } from 'app/shared/monaco-editor/model/actions/monaco-code.action'; import { MonacoCodeBlockAction } from 'app/shared/monaco-editor/model/actions/monaco-code-block.action'; -import { MarkdownEditorMonacoComponent } from 'app/shared/markdown-editor/monaco/markdown-editor-monaco.component'; +import { MarkdownEditorHeight, MarkdownEditorMonacoComponent } from 'app/shared/markdown-editor/monaco/markdown-editor-monaco.component'; import { MonacoChannelReferenceAction } from 'app/shared/monaco-editor/model/actions/communication/monaco-channel-reference.action'; import { MonacoUserMentionAction } from 'app/shared/monaco-editor/model/actions/communication/monaco-user-mention.action'; import { MonacoExerciseReferenceAction } from 'app/shared/monaco-editor/model/actions/communication/monaco-exercise-reference.action'; diff --git a/src/main/webapp/app/shared/monaco-editor/model/actions/communication/monaco-exercise-reference.action.ts b/src/main/webapp/app/shared/monaco-editor/model/actions/communication/monaco-exercise-reference.action.ts index 014894dedd42..846241c6dd94 100644 --- a/src/main/webapp/app/shared/monaco-editor/model/actions/communication/monaco-exercise-reference.action.ts +++ b/src/main/webapp/app/shared/monaco-editor/model/actions/communication/monaco-exercise-reference.action.ts @@ -2,7 +2,7 @@ import { TranslateService } from '@ngx-translate/core'; import * as monaco from 'monaco-editor'; import { MetisService } from 'app/shared/metis/metis.service'; import { MonacoEditorDomainActionWithOptions } from 'app/shared/monaco-editor/model/actions/monaco-editor-domain-action-with-options.model'; -import { ValueItem } from 'app/shared/markdown-editor/command-constants'; +import { ValueItem } from 'app/shared/markdown-editor/value-item.model'; /** * Action to insert a reference to an exercise into the editor. Users that type a / will see a list of available exercises to reference. diff --git a/src/main/webapp/app/shared/monaco-editor/model/actions/monaco-editor-domain-action-with-options.model.ts b/src/main/webapp/app/shared/monaco-editor/model/actions/monaco-editor-domain-action-with-options.model.ts index f5723fef6394..83969d07626d 100644 --- a/src/main/webapp/app/shared/monaco-editor/model/actions/monaco-editor-domain-action-with-options.model.ts +++ b/src/main/webapp/app/shared/monaco-editor/model/actions/monaco-editor-domain-action-with-options.model.ts @@ -1,5 +1,5 @@ import { MonacoEditorDomainAction } from './monaco-editor-domain-action.model'; -import { ValueItem } from 'app/shared/markdown-editor/command-constants'; +import { ValueItem } from 'app/shared/markdown-editor/value-item.model'; export interface DomainActionWithOptionsArguments { selectedItem: ValueItem; diff --git a/src/main/webapp/app/shared/monaco-editor/model/actions/monaco-task.action.ts b/src/main/webapp/app/shared/monaco-editor/model/actions/monaco-task.action.ts index e1fe3f87b53e..a55f00bd9d1d 100644 --- a/src/main/webapp/app/shared/monaco-editor/model/actions/monaco-task.action.ts +++ b/src/main/webapp/app/shared/monaco-editor/model/actions/monaco-task.action.ts @@ -1,5 +1,6 @@ import * as monaco from 'monaco-editor'; import { MonacoEditorDomainAction } from 'app/shared/monaco-editor/model/actions/monaco-editor-domain-action.model'; +import { escapeStringForUseInRegex } from 'app/shared/util/global.utils'; /** * Action to insert a task into the editor. They follow the format [task][Task Short Description](testCaseName). @@ -7,6 +8,8 @@ import { MonacoEditorDomainAction } from 'app/shared/monaco-editor/model/actions export class MonacoTaskAction extends MonacoEditorDomainAction { static readonly ID = 'monaco-task.action'; static readonly TEXT = '[Task Short Description](testCaseName)\n'; + static readonly IDENTIFIER = '[task]'; + static readonly GLOBAL_TASK_REGEX = new RegExp(`${escapeStringForUseInRegex(MonacoTaskAction.IDENTIFIER)}(.*)`, 'g'); constructor() { super(MonacoTaskAction.ID, 'artemisApp.programmingExercise.problemStatement.taskCommand', undefined, undefined); @@ -22,6 +25,6 @@ export class MonacoTaskAction extends MonacoEditorDomainAction { } getOpeningIdentifier(): string { - return '[task]'; + return MonacoTaskAction.IDENTIFIER; } } diff --git a/src/main/webapp/app/shared/monaco-editor/model/monaco-editor-overlay-widget.model.ts b/src/main/webapp/app/shared/monaco-editor/model/monaco-editor-overlay-widget.model.ts index d138ae87df1c..8ea50ee8898d 100644 --- a/src/main/webapp/app/shared/monaco-editor/model/monaco-editor-overlay-widget.model.ts +++ b/src/main/webapp/app/shared/monaco-editor/model/monaco-editor-overlay-widget.model.ts @@ -20,7 +20,7 @@ export class MonacoEditorOverlayWidget extends MonacoCodeEditorElement implement constructor(editor: monaco.editor.ICodeEditor, id: string, domNode: HTMLElement, position: OverlayWidgetPosition) { super(editor, id); this.domNode = domNode; - // At the moment, the inline feedback nodes will only reach their maximum width with the following line. This workaround can be removed as soon as the Ace editor has been replaced. + // Ensure that the widget reaches its maximum width. this.domNode.style.width = '100%'; this.position = position; } diff --git a/src/main/webapp/app/shared/monaco-editor/monaco-editor.component.ts b/src/main/webapp/app/shared/monaco-editor/monaco-editor.component.ts index 1f28308ecb11..0e63c2081074 100644 --- a/src/main/webapp/app/shared/monaco-editor/monaco-editor.component.ts +++ b/src/main/webapp/app/shared/monaco-editor/monaco-editor.component.ts @@ -11,7 +11,9 @@ import { MonacoEditorAction } from 'app/shared/monaco-editor/model/actions/monac import { TranslateService } from '@ngx-translate/core'; import { MonacoEditorOptionPreset } from 'app/shared/monaco-editor/model/monaco-editor-option-preset.model'; -type EditorPosition = { row: number; column: number }; +export const MAX_TAB_SIZE = 8; +export type EditorPosition = { lineNumber: number; column: number }; + @Component({ selector: 'jhi-monaco-editor', template: '', @@ -155,14 +157,12 @@ export class MonacoEditorComponent implements OnInit, OnDestroy { } } - // Workaround: The rest of the code expects { row, column } - we have { lineNumber, column }. Can be removed when Ace is removed. getPosition(): EditorPosition { - const position = this._editor.getPosition() ?? new monaco.Position(0, 0); - return { row: position.lineNumber, column: position.column }; + return this._editor.getPosition() ?? { column: 0, lineNumber: 0 }; } setPosition(position: EditorPosition) { - this._editor.setPosition({ lineNumber: position.row, column: position.column }); + this._editor.setPosition(position); } setSelection(range: monaco.IRange): void { diff --git a/src/main/webapp/app/shared/range-slider/range-slider.component.html b/src/main/webapp/app/shared/range-slider/range-slider.component.html new file mode 100644 index 000000000000..cf6170502703 --- /dev/null +++ b/src/main/webapp/app/shared/range-slider/range-slider.component.html @@ -0,0 +1,32 @@ +
+
+
+ {{ selectedMinValue }}{{ labelSymbol }} + {{ selectedMaxValue }}{{ labelSymbol }} + + + +
+
diff --git a/src/main/webapp/app/shared/range-slider/range-slider.component.scss b/src/main/webapp/app/shared/range-slider/range-slider.component.scss new file mode 100644 index 000000000000..2342957d2e6d --- /dev/null +++ b/src/main/webapp/app/shared/range-slider/range-slider.component.scss @@ -0,0 +1,65 @@ +$height: 0.5rem; +$border-radius: 0.5rem; + +$slider-thumb-size: 1.2rem; +$slider-thumb-border-radius: 50%; +$slider-thumb-color: var(--primary); + +.slider { + height: $height; + border-radius: $border-radius; + background: var(--gray-400); +} + +.slider .progress { + height: $height; + border-radius: $border-radius; + position: absolute; + background: var(--primary); +} + +.range-input { + position: relative; +} + +.range-input input { + height: $height; + position: absolute; + width: 100%; + background: none; + pointer-events: none; + -webkit-appearance: none; +} + +/* Covers Chrome and Safari */ +[type='range']::-webkit-slider-thumb { + height: $slider-thumb-size; + width: $slider-thumb-size; + border-radius: $slider-thumb-border-radius; + background: $slider-thumb-color; + pointer-events: auto; + -webkit-appearance: none; + cursor: pointer; +} + +/* covers Mozilla Firefox */ +[type='range']::-moz-range-thumb { + height: $slider-thumb-size; + width: $slider-thumb-size; + border-radius: $slider-thumb-border-radius; + background: $slider-thumb-color; + pointer-events: auto; + -moz-appearance: none; + cursor: pointer; +} + +.slider-value { + position: absolute; + bottom: -2.2rem; + pointer-events: none; // we do not want to click the label but the range-thumb + color: var(--secondary); + text-align: center; + justify-content: center; + align-content: center; + display: flex; +} diff --git a/src/main/webapp/app/shared/range-slider/range-slider.component.ts b/src/main/webapp/app/shared/range-slider/range-slider.component.ts new file mode 100644 index 000000000000..1082ee793ca3 --- /dev/null +++ b/src/main/webapp/app/shared/range-slider/range-slider.component.ts @@ -0,0 +1,141 @@ +import { Component, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; + +const DEFAULT_STEP = 1; + +@Component({ + selector: 'jhi-range-slider', + templateUrl: './range-slider.component.html', + styleUrls: ['./range-slider.component.scss'], + standalone: true, + imports: [FormsModule, ReactiveFormsModule], +}) +export class RangeSliderComponent implements OnInit, OnDestroy { + @Input() generalMaxValue: number; + @Input() generalMinValue: number; + @Input() step: number = DEFAULT_STEP; + + /** When extending the supported label symbols you might have to adjust the logic for */ + @Input() labelSymbol?: '%'; + + @Input() selectedMinValue: number; + @Input() selectedMaxValue: number; + @Output() selectedMinValueChange: EventEmitter = new EventEmitter(); + @Output() selectedMaxValueChange: EventEmitter = new EventEmitter(); + + rangeInputElements?: NodeList; + eventListeners: { element: HTMLInputElement; listener: (event: Event) => void }[] = []; + + sliderMinPercentage: number; + sliderMaxPercentage: number; + + valueRange: number; + + /** Ensures that the label is placed centered underneath the range thumb */ + LABEL_MARGIN = 0.4; + + /** + * By trial and error it was found out that the slider thumbs are moving on + * 97% of the width compared to the colored bar that is displayed between the two thumbs. + * + * This issue is resolved with this factor when multiplied to {@link sliderMinPercentage} and {@link sliderMaxPercentage} + * to calculate the position of the label, as it is not the exact same position as the thumbs. + * + * + * To reproduce: + * If you inspect the progress bar in the initial state you will see that it is 100% wide and ends at the left end of + * the minimum range thumb. + * However, if you move the minimum thumb to the right (as far as possible), you will notice that the progress bar + * ends at the right end of the range thumb. - This is the problem that we address with this factor. + */ + SLIDER_THUMB_LABEL_POSITION_ADJUSTMENT_FACTOR = 0.97; + + constructor(private elRef: ElementRef) {} + + ngOnInit() { + this.rangeInputElements = this.elRef.nativeElement.querySelectorAll('.range-input input'); + + this.rangeInputElements?.forEach((input: HTMLInputElement) => { + const listener = (event: InputEvent) => { + this.ensureMinValueIsSmallerThanMaxValueViceVersa(event); + }; + input.addEventListener('input', listener); + this.eventListeners.push({ element: input, listener }); + }); + this.valueRange = this.generalMaxValue - this.generalMinValue; + + this.LABEL_MARGIN = this.getLabelMargin(); + + this.updateMinPercentage(); + this.updateMaxPercentage(); + } + + ngOnDestroy() { + this.eventListeners.forEach(({ element, listener }) => { + element.removeEventListener('input', listener); + }); + } + + updateMinPercentage() { + let newMinSelection = this.selectedMinValue; + + const tryingToSelectInvalidValue = this.selectedMinValue >= this.selectedMaxValue; + if (tryingToSelectInvalidValue) { + newMinSelection = this.selectedMaxValue - this.step; + } + + // noinspection UnnecessaryLocalVariableJS: not inlined because the variable name improves readability + const newMinPercentage = ((newMinSelection - this.generalMinValue) / this.valueRange) * 100; + this.sliderMinPercentage = newMinPercentage; + } + + updateMaxPercentage() { + let newMaxSelection = this.selectedMaxValue; + + const tryingToSelectInvalidValue = this.selectedMaxValue <= this.selectedMinValue; + if (tryingToSelectInvalidValue) { + newMaxSelection = this.selectedMinValue + this.step; + } + + // noinspection UnnecessaryLocalVariableJS: not inlined because the variable name improves readability + const newMaxPercentage = 100 - ((newMaxSelection - this.generalMinValue) / this.valueRange) * 100; + this.sliderMaxPercentage = newMaxPercentage; + } + + onSelectedMinValueChanged(event: Event): void { + const updatedMinValue = this.ensureMinValueIsSmallerThanMaxValueViceVersa(event); + this.selectedMinValueChange.emit(updatedMinValue); + } + + onSelectedMaxValueChanged(event: Event): void { + const updatedMaxValue = this.ensureMinValueIsSmallerThanMaxValueViceVersa(event); + this.selectedMaxValueChange.emit(updatedMaxValue); + } + + private ensureMinValueIsSmallerThanMaxValueViceVersa(event: Event): number { + const input = event.target as HTMLInputElement; + const minSliderIsUpdated = input.className.includes('range-min'); + + if (minSliderIsUpdated) { + if (this.selectedMinValue >= this.selectedMaxValue) { + this.selectedMinValue = this.selectedMaxValue - this.step; + } + return this.selectedMinValue; + } + + if (this.selectedMaxValue <= this.selectedMinValue) { + this.selectedMaxValue = this.selectedMinValue + this.step; + } + return this.selectedMaxValue; + } + + /** + * @return margin to labels considering the adjustments needed by the added {@link labelSymbol} + */ + private getLabelMargin() { + const BASE_LABEL_MARGIN = 0.4; // should be approximately the width of 1 symbol + const shiftToTheLeftDueToAddedSymbols = BASE_LABEL_MARGIN * (this.labelSymbol?.length ?? 0); + + return BASE_LABEL_MARGIN - shiftToTheLeftDueToAddedSymbols; + } +} diff --git a/src/main/webapp/app/shared/sidebar/sidebar-accordion/sidebar-accordion.component.ts b/src/main/webapp/app/shared/sidebar/sidebar-accordion/sidebar-accordion.component.ts index 5fef73b00b99..5cd59c0d3b8e 100644 --- a/src/main/webapp/app/shared/sidebar/sidebar-accordion/sidebar-accordion.component.ts +++ b/src/main/webapp/app/shared/sidebar/sidebar-accordion/sidebar-accordion.component.ts @@ -24,10 +24,10 @@ export class SidebarAccordionComponent implements OnChanges, OnInit { @Input() showAddOption?: ChannelAccordionShowAdd; @Input() channelTypeIcon?: ChannelTypeIcons; @Input() collapseState: CollapseState; + @Input() isFilterActive: boolean = false; - //icon - faChevronRight = faChevronRight; - faFile = faFile; + readonly faChevronRight = faChevronRight; + readonly faFile = faFile; ngOnInit() { this.expandGroupWithSelectedItem(); @@ -35,7 +35,7 @@ export class SidebarAccordionComponent implements OnChanges, OnInit { } ngOnChanges() { - if (this.searchValue) { + if (this.searchValue || this.isFilterActive) { this.expandAll(); } else { this.setStoredCollapseState(); diff --git a/src/main/webapp/app/shared/sidebar/sidebar.component.html b/src/main/webapp/app/shared/sidebar/sidebar.component.html index c9ff6c8a64ae..ea513b48174e 100644 --- a/src/main/webapp/app/shared/sidebar/sidebar.component.html +++ b/src/main/webapp/app/shared/sidebar/sidebar.component.html @@ -4,12 +4,28 @@ @if (searchFieldEnabled) { } @if (!sidebarData?.ungroupedData || !(sidebarData?.ungroupedData | searchFilter: ['title', 'type'] : searchValue)?.length) {
} @else { @@ -36,6 +52,7 @@ [channelTypeIcon]="channelTypeIcon" [collapseState]="collapseState" (onUpdateSidebar)="onUpdateSidebar.emit()" + [isFilterActive]="isFilterActive" /> } @else { @for (sidebarItem of sidebarData?.ungroupedData | searchFilter: ['title', 'type'] : searchValue; track sidebarItem; let last = $last) { diff --git a/src/main/webapp/app/shared/sidebar/sidebar.component.ts b/src/main/webapp/app/shared/sidebar/sidebar.component.ts index fdb0d9adcaaa..de47ff395f76 100644 --- a/src/main/webapp/app/shared/sidebar/sidebar.component.ts +++ b/src/main/webapp/app/shared/sidebar/sidebar.component.ts @@ -1,10 +1,20 @@ import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output } from '@angular/core'; -import { faChevronRight, faFilter, faMagnifyingGlass } from '@fortawesome/free-solid-svg-icons'; +import { faFilter, faFilterCircleXmark } from '@fortawesome/free-solid-svg-icons'; import { ActivatedRoute, Params } from '@angular/router'; import { Subscription, distinctUntilChanged } from 'rxjs'; import { ProfileService } from '../layouts/profiles/profile.service'; import { ChannelAccordionShowAdd, ChannelTypeIcons, CollapseState, SidebarCardSize, SidebarData, SidebarTypes } from 'app/types/sidebar'; import { SidebarEventService } from './sidebar-event.service'; +import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; +import { cloneDeep } from 'lodash-es'; +import { ExerciseFilterOptions, ExerciseFilterResults } from 'app/types/exercise-filter'; +import { + getAchievablePointsAndAchievedScoreFilterOptions, + getExerciseCategoryFilterOptions, + getExerciseDifficultyFilterOptions, + getExerciseTypeFilterOptions, +} from 'app/shared/sidebar/sidebar.helper'; +import { ExerciseFilterModalComponent } from 'app/shared/exercise-filter/exercise-filter-modal.component'; @Component({ selector: 'jhi-sidebar', @@ -22,6 +32,7 @@ export class SidebarComponent implements OnDestroy, OnChanges, OnInit { @Input() showAddOption?: ChannelAccordionShowAdd; @Input() channelTypeIcon?: ChannelTypeIcons; @Input() collapseState: CollapseState; + @Input() showFilter: boolean = false; searchValue = ''; isCollapsed: boolean = false; @@ -32,19 +43,26 @@ export class SidebarComponent implements OnDestroy, OnChanges, OnInit { profileSubscription?: Subscription; sidebarEventSubscription?: Subscription; sidebarAccordionEventSubscription?: Subscription; + routeParams: Params; isProduction = true; isTestServer = false; - // icons - faMagnifyingGlass = faMagnifyingGlass; - faChevronRight = faChevronRight; - faFilter = faFilter; + private modalRef?: NgbModalRef; + + readonly faFilter = faFilter; + readonly faFilterCurrentlyApplied = faFilterCircleXmark; + + sidebarDataBeforeFiltering: SidebarData; + + exerciseFilters?: ExerciseFilterOptions; + isFilterActive: boolean = false; constructor( private route: ActivatedRoute, private profileService: ProfileService, private sidebarEventService: SidebarEventService, + private modalService: NgbModal, ) {} ngOnInit(): void { @@ -110,4 +128,43 @@ export class SidebarComponent implements OnDestroy, OnChanges, OnInit { }; return this.sidebarData.sidebarType ? size[this.sidebarData.sidebarType] : 'M'; } + + openFilterExercisesDialog() { + this.initializeFilterOptions(); + + if (!this.sidebarDataBeforeFiltering) { + this.sidebarDataBeforeFiltering = cloneDeep(this.sidebarData); + } + + this.modalRef = this.modalService.open(ExerciseFilterModalComponent, { + size: 'lg', + backdrop: 'static', + animation: true, + }); + + this.modalRef.componentInstance.sidebarData = cloneDeep(this.sidebarDataBeforeFiltering); + this.modalRef.componentInstance.exerciseFilters = cloneDeep(this.exerciseFilters); + + this.modalRef.componentInstance.filterApplied.subscribe((exerciseFilterResults: ExerciseFilterResults) => { + this.sidebarData = exerciseFilterResults.filteredSidebarData!; + this.exerciseFilters = exerciseFilterResults.appliedExerciseFilters; + this.isFilterActive = exerciseFilterResults.isFilterActive; + }); + } + + initializeFilterOptions() { + if (this.exerciseFilters) { + return; + } + + const scoreAndPointsFilterOptions = getAchievablePointsAndAchievedScoreFilterOptions(this.sidebarData, this.exerciseFilters); + + this.exerciseFilters = { + categoryFilter: getExerciseCategoryFilterOptions(this.sidebarData, this.exerciseFilters), + exerciseTypesFilter: getExerciseTypeFilterOptions(this.sidebarData, this.exerciseFilters), + difficultyFilter: getExerciseDifficultyFilterOptions(this.sidebarData, this.exerciseFilters), + achievedScore: scoreAndPointsFilterOptions?.achievedScore, + achievablePoints: scoreAndPointsFilterOptions?.achievablePoints, + }; + } } diff --git a/src/main/webapp/app/shared/sidebar/sidebar.helper.ts b/src/main/webapp/app/shared/sidebar/sidebar.helper.ts new file mode 100644 index 000000000000..c77e558b5390 --- /dev/null +++ b/src/main/webapp/app/shared/sidebar/sidebar.helper.ts @@ -0,0 +1,269 @@ +import { DifficultyFilterOption, ExerciseCategoryFilterOption, ExerciseFilterOptions, ExerciseTypeFilterOption, FilterOption, RangeFilter } from 'app/types/exercise-filter'; +import { SidebarCardElement, SidebarData } from 'app/types/sidebar'; +import { ExerciseCategory } from 'app/entities/exercise-category.model'; +import { DifficultyLevel, ExerciseType, getIcon } from 'app/entities/exercise.model'; +import { getLatestResultOfStudentParticipation } from 'app/exercises/shared/participation/participation.utils'; +import { roundToNextMultiple } from 'app/shared/util/utils'; + +const POINTS_STEP = 1; +const SCORE_THRESHOLD_TO_INCREASE_STEP = 20; +const SMALL_SCORE_STEP = 1; +const SCORE_STEP = 5; + +const DEFAULT_DIFFICULTIES_FILTER: DifficultyFilterOption[] = [ + { name: 'artemisApp.exercise.easy', value: DifficultyLevel.EASY, checked: false }, + { name: 'artemisApp.exercise.medium', value: DifficultyLevel.MEDIUM, checked: false }, + { name: 'artemisApp.exercise.hard', value: DifficultyLevel.HARD, checked: false }, +]; + +const DEFAULT_EXERCISE_TYPES_FILTER: ExerciseTypeFilterOption[] = [ + { name: 'artemisApp.courseStatistics.programming', value: ExerciseType.PROGRAMMING, checked: false, icon: getIcon(ExerciseType.PROGRAMMING) }, + { name: 'artemisApp.courseStatistics.quiz', value: ExerciseType.QUIZ, checked: false, icon: getIcon(ExerciseType.QUIZ) }, + { name: 'artemisApp.courseStatistics.modeling', value: ExerciseType.MODELING, checked: false, icon: getIcon(ExerciseType.MODELING) }, + { name: 'artemisApp.courseStatistics.text', value: ExerciseType.TEXT, checked: false, icon: getIcon(ExerciseType.TEXT) }, + { name: 'artemisApp.courseStatistics.file-upload', value: ExerciseType.FILE_UPLOAD, checked: false, icon: getIcon(ExerciseType.FILE_UPLOAD) }, +]; + +function getAvailableCategoriesAsFilterOptions(sidebarData?: SidebarData): ExerciseCategoryFilterOption[] | undefined { + const sidebarElementsWithExerciseCategory: SidebarCardElement[] | undefined = sidebarData?.ungroupedData?.filter( + (sidebarElement: SidebarCardElement) => sidebarElement.exercise?.categories !== undefined, + ); + const availableCategories: ExerciseCategory[] | undefined = sidebarElementsWithExerciseCategory?.flatMap( + (sidebarElement: SidebarCardElement) => sidebarElement.exercise?.categories || [], + ); + + // noinspection UnnecessaryLocalVariableJS: not inlined because the variable name improves readability + const availableCategoriesAsFilterOptions: ExerciseCategoryFilterOption[] | undefined = availableCategories?.map((category: ExerciseCategory) => ({ + category: category, + searched: false, + })); + return availableCategoriesAsFilterOptions; +} + +function getExerciseCategoryFilterOptionsWithoutDuplicates(exerciseCategoryFilterOptions?: ExerciseCategoryFilterOption[]): ExerciseCategoryFilterOption[] | undefined { + return exerciseCategoryFilterOptions?.reduce((unique: ExerciseCategoryFilterOption[], item: ExerciseCategoryFilterOption) => { + if (!unique.some((uniqueItem) => uniqueItem.category.equals(item.category))) { + unique.push(item); + } + return unique; + }, []); +} + +function sortExerciseCategoryFilterOptionsSortedByName(exerciseCategoryFilterOptions?: ExerciseCategoryFilterOption[]): ExerciseCategoryFilterOption[] { + return exerciseCategoryFilterOptions?.sort((categoryFilterOptionsA, categoryFilterOptionB) => categoryFilterOptionsA.category.compare(categoryFilterOptionB.category)) ?? []; +} + +/** + * @param exerciseFilters that might already be defined for the course sidebar + * @param sidebarData that contains the exercises of a course and their information + * + * @returns already defined category filter options if they exist, otherwise the category filter options based on the sidebar data + */ +export function getExerciseCategoryFilterOptions(sidebarData?: SidebarData, exerciseFilters?: ExerciseFilterOptions): FilterOption { + if (exerciseFilters?.categoryFilter) { + return exerciseFilters?.categoryFilter; + } + + const availableCategoriesAsFilterOptions = getAvailableCategoriesAsFilterOptions(sidebarData); + const selectableCategoryFilterOptions = getExerciseCategoryFilterOptionsWithoutDuplicates(availableCategoriesAsFilterOptions); + const sortedCategoryFilterOptions = sortExerciseCategoryFilterOptionsSortedByName(selectableCategoryFilterOptions); + + const isDisplayed = !!sortedCategoryFilterOptions.length; + return { isDisplayed: isDisplayed, options: selectableCategoryFilterOptions ?? [] }; +} + +/** + * @param exerciseFilters that might already be defined for the course sidebar + * @param sidebarData that contains the exercises of a course and their information + * + * @returns already defined exercise type filter options if they exist, otherwise the exercise type filter options based on the sidebar data + */ +export function getExerciseTypeFilterOptions(sidebarData?: SidebarData, exerciseFilters?: ExerciseFilterOptions): FilterOption { + if (exerciseFilters?.exerciseTypesFilter) { + return exerciseFilters?.exerciseTypesFilter; + } + + const existingExerciseTypes = sidebarData?.ungroupedData + ?.filter((sidebarElement: SidebarCardElement) => sidebarElement.type !== undefined) + .map((sidebarElement: SidebarCardElement) => sidebarElement.type); + + const availableTypeFilters = DEFAULT_EXERCISE_TYPES_FILTER?.filter((exerciseType) => existingExerciseTypes?.includes(exerciseType.value)); + + return { isDisplayed: availableTypeFilters.length > 1, options: availableTypeFilters }; +} + +/** + * @param exerciseFilters that might already be defined for the course sidebar + * @param sidebarData that contains the exercises of a course and their information + * + * @returns already defined difficulty filter options if they exist, otherwise the difficulty filter options based on the sidebar data + */ +export function getExerciseDifficultyFilterOptions(sidebarData?: SidebarData, exerciseFilters?: ExerciseFilterOptions): FilterOption { + if (exerciseFilters?.difficultyFilter) { + return exerciseFilters.difficultyFilter; + } + + const existingDifficulties = sidebarData?.ungroupedData + ?.filter((sidebarElement: SidebarCardElement) => sidebarElement.difficulty !== undefined) + .map((sidebarElement: SidebarCardElement) => sidebarElement.difficulty); + + const availableDifficultyFilters = DEFAULT_DIFFICULTIES_FILTER?.filter((difficulty) => existingDifficulties?.includes(difficulty.value)); + + return { isDisplayed: !!availableDifficultyFilters.length, options: availableDifficultyFilters }; +} + +export function isRangeFilterApplied(rangeFilter?: RangeFilter): boolean { + if (!rangeFilter?.filter) { + return false; + } + + const filter = rangeFilter.filter; + const isExcludingMinValues = filter.selectedMin !== filter.generalMin; + const isExcludingMaxValues = filter.selectedMax !== filter.generalMax; + return isExcludingMinValues || isExcludingMaxValues; +} + +function getUpdatedMinAndMaxValues(minValue: number, maxValue: number, currentMaxValue: number) { + let updatedMinValue = minValue; + let updatedMaxValue = maxValue; + + if (currentMaxValue < minValue) { + updatedMinValue = currentMaxValue; + } + if (currentMaxValue > maxValue) { + updatedMaxValue = currentMaxValue; + } + + return { updatedMinValue, updatedMaxValue }; +} + +/** + * The calculation for points and score are intentionally mixed into one method to reduce the number of iterations over the sidebar data. + * @param sidebarData + */ +function calculateMinAndMaxForPointsAndScore(sidebarData: SidebarData) { + let minAchievablePoints = Infinity; + let maxAchievablePoints = -Infinity; + + let minAchievedScore = Infinity; + let maxAchievedScore = -Infinity; + + sidebarData.ungroupedData?.forEach((sidebarElement: SidebarCardElement) => { + if (sidebarElement.exercise?.maxPoints) { + const currentExerciseMaxPoints = sidebarElement.exercise.maxPoints; + + const { updatedMinValue, updatedMaxValue } = getUpdatedMinAndMaxValues(minAchievablePoints, maxAchievablePoints, currentExerciseMaxPoints); + minAchievablePoints = updatedMinValue; + maxAchievablePoints = updatedMaxValue; + + if (sidebarElement.studentParticipation) { + const currentExerciseAchievedScore = getLatestResultOfStudentParticipation(sidebarElement.studentParticipation, true)?.score; + + if (currentExerciseAchievedScore !== undefined) { + const { updatedMinValue, updatedMaxValue } = getUpdatedMinAndMaxValues(minAchievedScore, maxAchievedScore, currentExerciseAchievedScore); + minAchievedScore = updatedMinValue; + maxAchievedScore = updatedMaxValue; + } + } + } + }); + + return { minAchievablePoints, maxAchievablePoints, minAchievedScore, maxAchievedScore }; +} + +/** + * **Rounds the min and max values for achievable points and achieved score to the next multiple of the step. + * The step {@link POINTS_STEP}, and {@link SCORE_STEP} or {@link SMALL_SCORE_STEP} are the selectable values for the range filter.** + *
+ * For the **score filter**, the step is increased if we have more than 20 values between the min and max value, + * as up to 100 values are theoretically possible.
+ * For the **achievable points filter**, the step is always 1 as exercises usually have between 1 and 15 points, + * so we do not need to increase the step and thereby limit accuracy of filter options.
+ * + * @param minAchievablePoints + * @param maxAchievablePoints + * @param minAchievedScore + * @param maxAchievedScore + */ +function roundRangeFilterMinAndMaxValues(minAchievablePoints: number, maxAchievablePoints: number, minAchievedScore: number, maxAchievedScore: number) { + const roundUp = true; + const roundDown = false; + const minAchievablePointsRounded = roundToNextMultiple(minAchievablePoints, POINTS_STEP, roundDown); + const maxAchievablePointsRounded = roundToNextMultiple(maxAchievablePoints, POINTS_STEP, roundUp); + + let minAchievedScoreRounded; + let maxAchievedScoreRounded; + + if (maxAchievedScore > SCORE_THRESHOLD_TO_INCREASE_STEP) { + minAchievedScoreRounded = roundToNextMultiple(minAchievedScore, SCORE_STEP, roundDown); + maxAchievedScoreRounded = roundToNextMultiple(maxAchievedScore, SCORE_STEP, roundUp); + } else { + minAchievedScoreRounded = roundToNextMultiple(minAchievedScore, SMALL_SCORE_STEP, roundDown); + maxAchievedScoreRounded = roundToNextMultiple(maxAchievedScore, SMALL_SCORE_STEP, roundUp); + } + + return { minAchievablePointsRounded, maxAchievablePointsRounded, minAchievedScoreRounded, maxAchievedScoreRounded }; +} + +function calculateAchievablePointsFilterOptions(sidebarData: SidebarData): { achievablePoints?: RangeFilter; achievedScore?: RangeFilter } { + const { minAchievablePoints, maxAchievablePoints, minAchievedScore, maxAchievedScore } = calculateMinAndMaxForPointsAndScore(sidebarData); + + const { minAchievablePointsRounded, maxAchievablePointsRounded, minAchievedScoreRounded, maxAchievedScoreRounded } = roundRangeFilterMinAndMaxValues( + minAchievablePoints, + maxAchievablePoints, + minAchievedScore, + maxAchievedScore, + ); + + return { + achievablePoints: { + isDisplayed: minAchievablePointsRounded < maxAchievablePointsRounded, + filter: { + generalMin: minAchievablePointsRounded, + generalMax: maxAchievablePointsRounded, + selectedMin: minAchievablePointsRounded, + selectedMax: maxAchievablePointsRounded, + step: POINTS_STEP, + }, + }, + achievedScore: { + isDisplayed: minAchievedScoreRounded < maxAchievedScoreRounded && minAchievedScoreRounded !== Infinity, + filter: { + generalMin: minAchievedScoreRounded, + generalMax: maxAchievedScoreRounded, + selectedMin: minAchievedScoreRounded, + selectedMax: maxAchievedScoreRounded, + step: maxAchievedScoreRounded <= SCORE_THRESHOLD_TO_INCREASE_STEP ? SMALL_SCORE_STEP : SCORE_STEP, + }, + }, + }; +} + +/** + * @param exerciseFilters that might already be defined for the course sidebar + * @param sidebarData that contains the exercises of a course and their information + * + * @returns already defined achievable points and achieved score filter options if they exist, otherwise the achievable points and achieved score filter options based on the sidebar data + */ +export function getAchievablePointsAndAchievedScoreFilterOptions( + sidebarData?: SidebarData, + exerciseFilters?: ExerciseFilterOptions, +): { + achievablePoints?: RangeFilter; + achievedScore?: RangeFilter; +} { + if (!sidebarData?.ungroupedData) { + return { achievablePoints: undefined, achievedScore: undefined }; + } + + const isPointsFilterApplied = isRangeFilterApplied(exerciseFilters?.achievablePoints); + const isScoreFilterApplied = isRangeFilterApplied(exerciseFilters?.achievedScore); + + const isRecalculatingFilterOptionsRequired = isPointsFilterApplied || isScoreFilterApplied || !exerciseFilters?.achievablePoints || !exerciseFilters?.achievedScore; + if (!isRecalculatingFilterOptionsRequired) { + // the scores might change when we work on exercises, so we re-calculate the filter options (but only if the filter is actually applied) + return { achievablePoints: exerciseFilters?.achievablePoints, achievedScore: exerciseFilters?.achievedScore }; + } + + return calculateAchievablePointsFilterOptions(sidebarData); +} diff --git a/src/main/webapp/app/shared/statistics-graph/statistics.service.ts b/src/main/webapp/app/shared/statistics-graph/statistics.service.ts index 27b83a0eac33..233a10bfd8d8 100644 --- a/src/main/webapp/app/shared/statistics-graph/statistics.service.ts +++ b/src/main/webapp/app/shared/statistics-graph/statistics.service.ts @@ -48,7 +48,7 @@ export class StatisticsService { const params = new HttpParams().set('courseId', '' + courseId); return this.http.get(`${this.resourceUrl}course-statistics`, { params }).pipe( map((res: CourseManagementStatisticsDTO) => { - StatisticsService.convertExerciseCategoriesOfrCourseManagementStatisticsFromServer(res); + StatisticsService.convertExerciseCategoriesOfCourseManagementStatisticsFromServer(res); return StatisticsService.convertCourseManagementStatisticDatesFromServer(res); }), ); @@ -78,9 +78,9 @@ export class StatisticsService { return dto; } - private static convertExerciseCategoriesOfrCourseManagementStatisticsFromServer(res: CourseManagementStatisticsDTO): CourseManagementStatisticsDTO { + private static convertExerciseCategoriesOfCourseManagementStatisticsFromServer(res: CourseManagementStatisticsDTO): CourseManagementStatisticsDTO { res.averageScoresOfExercises.forEach((avgScoresOfExercise) => { - avgScoresOfExercise.categories = avgScoresOfExercise.categories?.map((category) => JSON.parse(category as string) as ExerciseCategory); + avgScoresOfExercise.categories = avgScoresOfExercise.categories?.map((category) => new ExerciseCategory(category.category, category.color)); }); return res; } diff --git a/src/main/webapp/app/shared/user-settings/user-settings-container/user-settings-container.component.html b/src/main/webapp/app/shared/user-settings/user-settings-container/user-settings-container.component.html index 87f9a29c5d01..6391b4986739 100644 --- a/src/main/webapp/app/shared/user-settings/user-settings-container/user-settings-container.component.html +++ b/src/main/webapp/app/shared/user-settings/user-settings-container/user-settings-container.component.html @@ -26,7 +26,14 @@ > @if (localVCEnabled) { - + + + } diff --git a/src/main/webapp/app/shared/user-settings/user-settings.module.ts b/src/main/webapp/app/shared/user-settings/user-settings.module.ts index 98f82ef20bef..c006c7f5e904 100644 --- a/src/main/webapp/app/shared/user-settings/user-settings.module.ts +++ b/src/main/webapp/app/shared/user-settings/user-settings.module.ts @@ -8,9 +8,19 @@ import { userSettingsState } from 'app/shared/user-settings/user-settings.route' import { ScienceSettingsComponent } from 'app/shared/user-settings/science-settings/science-settings.component'; import { SshUserSettingsComponent } from 'app/shared/user-settings/ssh-settings/ssh-user-settings.component'; import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; +import { VcsAccessTokensSettingsComponent } from 'app/shared/user-settings/vcs-access-tokens-settings/vcs-access-tokens-settings.component'; +import { ClipboardModule } from '@angular/cdk/clipboard'; +import { FormDateTimePickerModule } from 'app/shared/date-time-picker/date-time-picker.module'; @NgModule({ - imports: [RouterModule.forChild(userSettingsState), ArtemisSharedModule, ArtemisSharedComponentModule], - declarations: [UserSettingsContainerComponent, AccountInformationComponent, NotificationSettingsComponent, ScienceSettingsComponent, SshUserSettingsComponent], + imports: [RouterModule.forChild(userSettingsState), ArtemisSharedModule, ArtemisSharedComponentModule, ClipboardModule, FormDateTimePickerModule], + declarations: [ + UserSettingsContainerComponent, + AccountInformationComponent, + NotificationSettingsComponent, + ScienceSettingsComponent, + SshUserSettingsComponent, + VcsAccessTokensSettingsComponent, + ], }) export class UserSettingsModule {} diff --git a/src/main/webapp/app/shared/user-settings/user-settings.route.ts b/src/main/webapp/app/shared/user-settings/user-settings.route.ts index 1e60f8d264c0..f27183c487cf 100644 --- a/src/main/webapp/app/shared/user-settings/user-settings.route.ts +++ b/src/main/webapp/app/shared/user-settings/user-settings.route.ts @@ -6,6 +6,7 @@ import { UserRouteAccessService } from 'app/core/auth/user-route-access-service' import { Authority } from 'app/shared/constants/authority.constants'; import { ScienceSettingsComponent } from 'app/shared/user-settings/science-settings/science-settings.component'; import { SshUserSettingsComponent } from 'app/shared/user-settings/ssh-settings/ssh-user-settings.component'; +import { VcsAccessTokensSettingsComponent } from 'app/shared/user-settings/vcs-access-tokens-settings/vcs-access-tokens-settings.component'; export const userSettingsState: Routes = [ { @@ -44,12 +45,19 @@ export const userSettingsState: Routes = [ }, }, { - path: 'sshSettings', + path: 'ssh', component: SshUserSettingsComponent, data: { pageTitle: 'artemisApp.userSettings.categories.SSH_SETTINGS', }, }, + { + path: 'vcs-token', + component: VcsAccessTokensSettingsComponent, + data: { + pageTitle: 'artemisApp.userSettings.categories.VCS_TOKEN_SETTINGS', + }, + }, ], }, ]; diff --git a/src/main/webapp/app/shared/user-settings/vcs-access-tokens-settings/vcs-access-tokens-settings.component.html b/src/main/webapp/app/shared/user-settings/vcs-access-tokens-settings/vcs-access-tokens-settings.component.html new file mode 100644 index 000000000000..5dc7ee5c55a7 --- /dev/null +++ b/src/main/webapp/app/shared/user-settings/vcs-access-tokens-settings/vcs-access-tokens-settings.component.html @@ -0,0 +1,130 @@ +

+ +

+@if (currentUser) { +
+
+
+ +
+
+ @if (!edit) { +
+
+ @if (this.currentUser?.vcsAccessToken) { + + + + + + + + + + + + + + + + + + +
+ + + + + +
*************** + + {{ this.currentUser?.vcsAccessTokenExpiryDate | artemisDate }} + +
+ } @else { +
+ } +
+
+ @if (!this.currentUser?.vcsAccessToken) { +
+
+
+ +
+
+
+ } + } + @if (edit) { +
+
+

+ +
+
+ +
+
+ +
+ + +
+
+
+ } +
+} diff --git a/src/main/webapp/app/shared/user-settings/vcs-access-tokens-settings/vcs-access-tokens-settings.component.ts b/src/main/webapp/app/shared/user-settings/vcs-access-tokens-settings/vcs-access-tokens-settings.component.ts new file mode 100644 index 000000000000..9214784ea8f6 --- /dev/null +++ b/src/main/webapp/app/shared/user-settings/vcs-access-tokens-settings/vcs-access-tokens-settings.component.ts @@ -0,0 +1,125 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { User } from 'app/core/user/user.model'; +import { AccountService } from 'app/core/auth/account.service'; +import { Subject, Subscription, tap } from 'rxjs'; +import dayjs from 'dayjs/esm'; +import { faBan, faCopy, faEdit, faSave, faTrash } from '@fortawesome/free-solid-svg-icons'; +import { ButtonSize, ButtonType } from 'app/shared/components/button.component'; +import { AlertService } from 'app/core/util/alert.service'; + +@Component({ + selector: 'jhi-account-information', + templateUrl: './vcs-access-tokens-settings.component.html', + styleUrls: ['../user-settings.scss'], +}) +export class VcsAccessTokensSettingsComponent implements OnInit, OnDestroy { + currentUser?: User; + + readonly faEdit = faEdit; + readonly faSave = faSave; + readonly faTrash = faTrash; + readonly faCopy = faCopy; + readonly faBan = faBan; + private authStateSubscription: Subscription; + expiryDate?: dayjs.Dayjs; + validExpiryDate = false; + wasCopied = false; + edit = false; + + private dialogErrorSource = new Subject(); + + dialogError$ = this.dialogErrorSource.asObservable(); + + protected readonly ButtonType = ButtonType; + protected readonly ButtonSize = ButtonSize; + + constructor( + private accountService: AccountService, + private alertService: AlertService, + ) {} + + ngOnInit() { + this.authStateSubscription = this.accountService + .getAuthenticationState() + .pipe( + tap((user: User) => { + this.currentUser = user; + return this.currentUser; + }), + ) + .subscribe(); + } + + ngOnDestroy(): void { + this.authStateSubscription.unsubscribe(); + } + + deleteVcsAccessToken() { + this.accountService.deleteUserVcsAccessToken().subscribe({ + next: () => { + if (this.currentUser) { + this.currentUser.vcsAccessTokenExpiryDate = undefined; + this.currentUser.vcsAccessToken = undefined; + } + this.alertService.success('artemisApp.userSettings.vcsAccessTokensSettingsPage.deleteSuccess'); + }, + error: () => { + this.alertService.error('artemisApp.userSettings.vcsAccessTokensSettingsPage.deleteFailure'); + }, + }); + this.dialogErrorSource.next(''); + } + + addNewVcsAccessToken() { + this.edit = true; + } + + sendTokenCreationRequest() { + if (!this.expiryDate || this.expiryDate.isBefore(dayjs()) || this.expiryDate.isAfter(dayjs().add(1, 'year'))) { + this.alertService.error('artemisApp.userSettings.vcsAccessTokensSettingsPage.addFailure'); + return; + } + this.accountService.addNewVcsAccessToken(this.expiryDate.toISOString()).subscribe({ + next: (res) => { + if (this.currentUser) { + const user = res.body as User; + this.currentUser.vcsAccessToken = user.vcsAccessToken; + this.currentUser.vcsAccessTokenExpiryDate = user.vcsAccessTokenExpiryDate; + this.edit = false; + } + this.alertService.success('artemisApp.userSettings.vcsAccessTokensSettingsPage.addSuccess'); + }, + error: () => { + this.alertService.error('artemisApp.userSettings.vcsAccessTokensSettingsPage.addFailure'); + }, + }); + } + + /** + * set wasCopied for 3 seconds on success + */ + onCopyFinished(successful: boolean) { + if (successful) { + this.wasCopied = true; + setTimeout(() => { + this.wasCopied = false; + }, 3000); + } + } + + /** + * Validates if the expiry date is after current time + */ + validateDate() { + this.validExpiryDate = !!this.expiryDate?.isAfter(dayjs()) && !!this.expiryDate?.isBefore(dayjs().add(1, 'year')); + } + + /** + * Cancel creation of a new token + */ + cancelTokenCreation() { + this.edit = false; + this.expiryDate = undefined; + this.validExpiryDate = false; + } +} diff --git a/src/main/webapp/app/shared/util/markdown.util.ts b/src/main/webapp/app/shared/util/markdown.util.ts index 973a0ad2c66e..69de712ce3f8 100644 --- a/src/main/webapp/app/shared/util/markdown.util.ts +++ b/src/main/webapp/app/shared/util/markdown.util.ts @@ -1,46 +1,18 @@ import { ExerciseHintExplanationInterface } from 'app/entities/quiz/quiz-question.model'; -import { hintCommentIdentifier } from 'app/shared/markdown-editor/domainCommands/hint.command'; -import { explanationCommandIdentifier } from 'app/shared/markdown-editor/domainCommands/explanation.command'; import { escapeStringForUseInRegex } from 'app/shared/util/global.utils'; +import { MonacoQuizExplanationAction } from 'app/shared/monaco-editor/model/actions/quiz/monaco-quiz-explanation.action'; +import { MonacoQuizHintAction } from 'app/shared/monaco-editor/model/actions/quiz/monaco-quiz-hint.action'; -const hintOrExpRegex = new RegExp(escapeStringForUseInRegex(`${explanationCommandIdentifier}`) + '|' + escapeStringForUseInRegex(`${hintCommentIdentifier}`), 'g'); - -/** - * adds the passed text into the editor of the passed ace editor component at the current curser by focusing, clearing a selection, - * moving the cursor to the end of the line, and finally inserting the given text. - * After that the new test will be selected - * - * @param text the text that will be added into the editor of the passed ace editor component - * @param aceEditor the editor in which the text will be added at the current curser position - */ -export function addTextAtCursor(text: string, aceEditor: any) { - aceEditor.focus(); - aceEditor.clearSelection(); - aceEditor.moveCursorTo(aceEditor.getCursorPosition().row, Number.POSITIVE_INFINITY); - aceEditor.insert(text); - const range = aceEditor.selection.getRange(); - const commandIdentifier = text.split(']'); - const offsetRange = commandIdentifier[0].length + 1; - range.setStart(range.start.row, offsetRange); - aceEditor.selection.setRange(range); -} - -/** - * Remove the text at the specified range. - * @param from = col & row from which to start - * @param to = col & row at which to end - * @param aceEditor the editor in which text should be removed - */ -export function removeTextRange(from: { col: number; row: number }, to: { col: number; row: number }, aceEditor: any) { - aceEditor.focus(); - aceEditor.getSession().remove({ startRow: from.row, startColumn: from.col, endRow: to.row, endColumn: to.col }); -} +const hintOrExpRegex = new RegExp( + escapeStringForUseInRegex(`${MonacoQuizExplanationAction.IDENTIFIER}`) + '|' + escapeStringForUseInRegex(`${MonacoQuizHintAction.IDENTIFIER}`), + 'g', +); /** * Parse the markdown text and apply the result to the target object's data * - * The markdown text is split at hintCommentIdentifier and explanationCommandIdentifier tags. - * => First part is text. Everything after hintCommentIdentifier is Hint, anything after explanationCommandIdentifier is explanation + * The markdown text is split at MonacoQuizHintAction.IDENTIFIER and MonacoQuizExplanationAction.IDENTIFIER tags. + * => First part is text. Everything after MonacoQuizHintAction.IDENTIFIER is Hint, anything after MonacoQuizExplanationAction.IDENTIFIER is explanation * * @param markdownText {string} the markdown text to parse * @param targetObject {object} the object that the result will be saved in. Fields modified are 'text', 'hint' and 'explanation'. @@ -52,18 +24,18 @@ export function parseExerciseHintExplanation(markdownText: string, targetObject: // split markdownText into main text, hint and explanation const markdownTextParts = markdownText.split(hintOrExpRegex); targetObject.text = markdownTextParts[0].trim(); - if (markdownText.indexOf(hintCommentIdentifier) !== -1 && markdownText.indexOf(explanationCommandIdentifier) !== -1) { - if (markdownText.indexOf(hintCommentIdentifier) < markdownText.indexOf(explanationCommandIdentifier)) { + if (markdownText.indexOf(MonacoQuizHintAction.IDENTIFIER) !== -1 && markdownText.indexOf(MonacoQuizExplanationAction.IDENTIFIER) !== -1) { + if (markdownText.indexOf(MonacoQuizHintAction.IDENTIFIER) < markdownText.indexOf(MonacoQuizExplanationAction.IDENTIFIER)) { targetObject.hint = markdownTextParts[1].trim(); targetObject.explanation = markdownTextParts[2].trim(); } else { targetObject.hint = markdownTextParts[2].trim(); targetObject.explanation = markdownTextParts[1].trim(); } - } else if (markdownText.indexOf(hintCommentIdentifier) !== -1) { + } else if (markdownText.indexOf(MonacoQuizHintAction.IDENTIFIER) !== -1) { targetObject.hint = markdownTextParts[1].trim(); targetObject.explanation = undefined; - } else if (markdownText.indexOf(explanationCommandIdentifier) !== -1) { + } else if (markdownText.indexOf(MonacoQuizExplanationAction.IDENTIFIER) !== -1) { targetObject.hint = undefined; targetObject.explanation = markdownTextParts[1].trim(); } else { @@ -88,6 +60,6 @@ export function generateExerciseHintExplanation(sourceObject: ExerciseHintExplan return !sourceObject.text ? '' : sourceObject.text + - (sourceObject.hint ? '\n\t' + hintCommentIdentifier + ' ' + sourceObject.hint : '') + - (sourceObject.explanation ? '\n\t' + explanationCommandIdentifier + ' ' + sourceObject.explanation : ''); + (sourceObject.hint ? '\n\t' + MonacoQuizHintAction.IDENTIFIER + ' ' + sourceObject.hint : '') + + (sourceObject.explanation ? '\n\t' + MonacoQuizExplanationAction.IDENTIFIER + ' ' + sourceObject.explanation : ''); } diff --git a/src/main/webapp/app/shared/util/utils.ts b/src/main/webapp/app/shared/util/utils.ts index a9ecd05f46d7..ed3df211540a 100644 --- a/src/main/webapp/app/shared/util/utils.ts +++ b/src/main/webapp/app/shared/util/utils.ts @@ -156,3 +156,18 @@ export function scrollToTopOfPage() { export function isExamExercise(exercise: Exercise) { return exercise.course === undefined; } + +/** + * Rounds a value up to the nearest multiple + * + * @param value that shall be rounded + * @param multiple to which we round up + * @param roundUp if true, we round up, otherwise we round down + */ +export function roundToNextMultiple(value: number, multiple: number, roundUp: boolean) { + if (roundUp) { + return Math.ceil(value / multiple) * multiple; + } + + return Math.floor(value / multiple) * multiple; +} diff --git a/src/main/webapp/app/shared/virtual-scroll/virtual-scroll.component.ts b/src/main/webapp/app/shared/virtual-scroll/virtual-scroll.component.ts index 48b52e4c9376..89ce35fb3766 100644 --- a/src/main/webapp/app/shared/virtual-scroll/virtual-scroll.component.ts +++ b/src/main/webapp/app/shared/virtual-scroll/virtual-scroll.component.ts @@ -114,7 +114,7 @@ export class VirtualScrollComponent implements OnInit } /** - * start listening to focusin events on initialization to prevent unintentional scrolling when the user focuses into the text area of ace editor component + * start listening to focusin events on initialization to prevent unintentional scrolling when the user focuses into the text area of code editor component * start listening to scroll events on initialization to perform virtual scrolling when the user scrolls through the item container * start listening to navigationStart events of router and enable forceReloadChange to scroll to the top of the updated item list */ @@ -172,7 +172,7 @@ export class VirtualScrollComponent implements OnInit } /** - * prevents automatic scrolling to other items when user clicks the text area of ace editor component + * prevents automatic scrolling to other items when user clicks the text area of code editor component */ onFocusIn() { window.scrollTo(0, this.windowScrollTop); diff --git a/src/main/webapp/app/types/exercise-filter.ts b/src/main/webapp/app/types/exercise-filter.ts new file mode 100644 index 000000000000..053dbb8d089c --- /dev/null +++ b/src/main/webapp/app/types/exercise-filter.ts @@ -0,0 +1,43 @@ +import { ExerciseCategory } from 'app/entities/exercise-category.model'; +import { DifficultyLevel, ExerciseType } from 'app/entities/exercise.model'; +import { SidebarData } from 'app/types/sidebar'; +import { IconProp } from '@fortawesome/fontawesome-svg-core'; + +/** + * isDisplayed - whether the filter is in the filter modal (e.g. for no sidebar element the difficulty is defined, so the difficulty filter is not displayed) + */ +export type FilterOption = { isDisplayed: boolean; options: T[] }; +export type ExerciseCategoryFilterOption = { category: ExerciseCategory; searched: boolean }; +export type ExerciseTypeFilterOption = { name: string; value: ExerciseType; checked: boolean; icon: IconProp }; +export type DifficultyFilterOption = { name: string; value: DifficultyLevel; checked: boolean }; + +export type RangeFilter = { + isDisplayed: boolean; + filter: { + generalMin: number; + generalMax: number; + selectedMin: number; + selectedMax: number; + step: number; + }; +}; + +export type ExerciseFilterOptions = { + categoryFilter?: FilterOption; + exerciseTypesFilter?: FilterOption; + difficultyFilter?: FilterOption; + achievedScore?: RangeFilter; + achievablePoints?: RangeFilter; +}; + +export type ExerciseFilterResults = { filteredSidebarData?: SidebarData; appliedExerciseFilters?: ExerciseFilterOptions; isFilterActive: boolean }; + +export type FilterDetails = { + searchedTypes?: ExerciseType[]; + selectedCategories: ExerciseCategory[]; + searchedDifficulties?: DifficultyLevel[]; + isScoreFilterApplied: boolean; + isPointsFilterApplied: boolean; + achievedScore?: RangeFilter; + achievablePoints?: RangeFilter; +}; diff --git a/src/main/webapp/i18n/de/competency.json b/src/main/webapp/i18n/de/competency.json index d6bdd4167ef2..f8c8630389e2 100644 --- a/src/main/webapp/i18n/de/competency.json +++ b/src/main/webapp/i18n/de/competency.json @@ -75,7 +75,7 @@ "saveModalTitle": "Speichern bestätigen", "saveModalText": "Du hast nicht alle Empfehlungen angesehen, möchtest du trotzdem fortfahren?", "errorWarning": "Speichern nicht möglich, weil fehlerhafte Kompetenzen vorliegen", - "loading": "Generiere Kompetenzvorschläge... Das kann eine Weile dauern.", + "loading": "Generiere Kompetenzvorschläge...", "courseDescription": { "title": "Kursbeschreibung eingeben", "placeholder": "Bitte gib eine Kursbeschreibung ein", @@ -86,7 +86,7 @@ "regenerateTooltip": "Zusätzliche Kompetenzvorschläge generieren", "infoTooltip": "Gib eine Beschreibung ein, die sich auf den theoretischen/praktischen Inhalt und Lernziele des Kurses fokussiert. Iris generiert daraus Vorschläge für Kompetenzen, die du in deinen Kurs einfügen könntest.", "success": "{{ noOfCompetencies }} Kompetenzvorschläge generiert.", - "warning": "Keine Kompetenzvorschläge generiert. Versuche, eine ausführlichere Kursbeschreibung zu verwenden. Wenn das Problem weiterhin besteht, wende dich an die Administratoren." + "warning": "Keine Kompetenzvorschläge generiert. Wenn das Problem weiterhin besteht, wende dich an die Administratoren." } }, "importAll": { diff --git a/src/main/webapp/i18n/de/exercise-actions.json b/src/main/webapp/i18n/de/exercise-actions.json index f45e3097bf3e..928496b308b8 100644 --- a/src/main/webapp/i18n/de/exercise-actions.json +++ b/src/main/webapp/i18n/de/exercise-actions.json @@ -69,6 +69,8 @@ "uploadFile": "Datei hochladen", "viewTeam": "Team", "sshKeyTip": "Um SSH zu nutzen, musst du {link:hier} einen SSH Schlüssel zu deinem Konto hinzufügen.", + "vcsTokenTip": "Um mit einem VCS-Zugriffstoken auf das Repository zuzugreifen, musst du {link:hier} ein neues hinzufügen.", + "vcsTokenExpiredTip": "Dein VCS-Zugriffstoken ist abgelaufen. Erneuere es {link:hier}.", "startExerciseBeforeStartDate": "Du kannst vor dem Startdatum nicht an der Aufgabe teilnehmen.", "deleteMultipleExercisesQuestion": "Sollen die ausgewählten Aufgaben wirklich dauerhaft gelöscht werden?" } diff --git a/src/main/webapp/i18n/de/exercise.json b/src/main/webapp/i18n/de/exercise.json index e198497deaa9..332f39024ff4 100644 --- a/src/main/webapp/i18n/de/exercise.json +++ b/src/main/webapp/i18n/de/exercise.json @@ -140,6 +140,7 @@ }, "categories": "Kategorien", "noCategory": "Keine Kategorie", + "selectCategories": "Kategorien auswählen", "participation": "Teilnahme", "participations": "Teilnahmen", "submissions": "Einreichungen", diff --git a/src/main/webapp/i18n/de/guidedTourProgrammingExercise.json b/src/main/webapp/i18n/de/guidedTourProgrammingExercise.json index 8baa5f33ad99..8cfd6706e6b3 100644 --- a/src/main/webapp/i18n/de/guidedTourProgrammingExercise.json +++ b/src/main/webapp/i18n/de/guidedTourProgrammingExercise.json @@ -10,7 +10,7 @@ "headline": "Klassendatei", "content": "Klick hier auf die Klassendatei, damit sie sich im Programmiereditor öffnet." }, - "aceEditor": { + "monacoEditor": { "headline": "Programmiereditor", "content": "Nun wird dir der Quellcode im Programmiereditor angezeigt. Du kannst dir das TODO durchlesen und bereits versuchen, dieses zu lösen. Alternativ kannst du auch in dieser Demo schon beliebigen Code einfügen. Wenn du damit fertig bist, dann klicke auf weiter." }, diff --git a/src/main/webapp/i18n/de/student-dashboard.json b/src/main/webapp/i18n/de/student-dashboard.json index 38820f89e17b..89729023ff10 100644 --- a/src/main/webapp/i18n/de/student-dashboard.json +++ b/src/main/webapp/i18n/de/student-dashboard.json @@ -33,7 +33,8 @@ "manage": "Verwalten", "studentView": "Studentenansicht", "general": { - "noDataFound": "Keine Einträge gefunden." + "noDataFound": "Keine Einträge gefunden.", + "noElementFoundWithAppliedFilter": "Keine Einträge für die angewendeten Filtereinstellungen gefunden." }, "sidebar": { "past": "Vorangegangen", @@ -89,6 +90,17 @@ "plagiarismCases": "Plagiatsfälle", "gradingSystem": "Notenschlüssel" }, + "exerciseFilter": { + "filter": "Filter", + "modalTitle": "Aufgaben Filtern", + "dueDateRange": "Zeitraum der Einreichungsfrist", + "achievedScore": "Erreichtes Ergebnis", + "achievablePoints": "Erreichbare Punktzahl", + "applyFilter": "Filter anwenden", + "resetFilter": "Filter zurücksetzen", + "noFilterAvailable": "Für die bisherigen Aufgaben gibt es keine unterscheidenden Filteroptionen", + "noMoreOptions": "Keine weiteren Auswahlmöglichkeiten" + }, "exerciseList": { "filter": "Filter ({{ num }})", "releaseDate": "Veröffentlichungsdatum", diff --git a/src/main/webapp/i18n/de/userSettings.json b/src/main/webapp/i18n/de/userSettings.json index 4898166be418..d7e1c3de5180 100644 --- a/src/main/webapp/i18n/de/userSettings.json +++ b/src/main/webapp/i18n/de/userSettings.json @@ -5,7 +5,8 @@ "saveChanges": "Änderungen speichern", "saveSettingsSuccessAlert": "Die Einstellungen wurden erfolgreich gespeichert.", "userSettings": "Benutzereinstellungen", - "sshSettings": "SSH Einstellungen", + "sshSettings": "SSH", + "vcsAccessTokenSettings": "VCS Token", "accountInformation": "Account Informationen", "notificationSettings": "Benachrichtigungen", "notificationSettingsFilterInfo": "Diese Einstellungen filtern auch die Seitenleiste für Mitteilungen", @@ -27,6 +28,21 @@ "sshKeyDisplayedInformation": "Das ist dein aktuell konfigurierter SSH-Schlüssel:", "key": "SSH Schlüssel" }, + "vcsAccessTokensSettingsPage": { + "addTokenTitle": "Neues Zugriffstoken erzeugen", + "infoText": "Du kannst ein persönliches Zugriffstoken generieren, um mit dem Artemis Local Version Control System zu interagieren. Verwende es um dich über HTTP bei Git zu authentifizieren.", + "deleteVcsAccessTokenQuestion": "Möchtest du dein Zugriffstoken für das Versionskontrollsystem wirklich löschen? Du kannst dich nicht mehr bei lokalen Repositories authentifizieren, die mit diesem Token geklont wurden.", + "createAccessToken": "Neues VCS Zugriffstoken generieren", + "noTokenSet": "Du hast kein Zugriffstoken", + "addToken": "Neues Token hinzufügen", + "deleteFailure": "Das Löschen des VCS-Zugriffstokens ist fehlgeschlagen", + "deleteSuccess": "Das VCS-Zugriffstoken wurde erfolgreich gelöscht", + "addFailure": "Neues VCS-Zugriffstoken konnte nicht gespeichert werden", + "addSuccess": "Neues VCS-Zugriffstoken erfolgreich erstellt", + "vcsAccessToken": "VCS-Zugriffstoken", + "expiryDate": "Ablaufdatum", + "actions": "Aktionen" + }, "accountInformationPage": { "registrationNumber": "Matrikelnummer", "fullName": "Vollständiger Name", @@ -38,7 +54,8 @@ "categories": { "NOTIFICATION_SETTINGS": "Benachrichtigungseinstellungen", "SCIENCE_SETTINGS": "Forschungseinstellungen", - "SSH_SETTINGS": "SSH" + "SSH_SETTINGS": "SSH Einstellungen", + "VCS_TOKEN_SETTINGS": "VCS Tokeneinstellungen" }, "settingGroupNames": { "weeklySummary": "Wöchentliche Zusammenfassung", diff --git a/src/main/webapp/i18n/en/competency.json b/src/main/webapp/i18n/en/competency.json index 908b59d271dd..54e4cd928076 100644 --- a/src/main/webapp/i18n/en/competency.json +++ b/src/main/webapp/i18n/en/competency.json @@ -75,7 +75,7 @@ "saveModalTitle": "Confirm Save", "saveModalText": "You have not viewed all recommendations, do you still want to continue?", "errorWarning": "You may not save because some competencies are invalid", - "loading": "Generating competency recommendations... This might take a while.", + "loading": "Generating competency recommendations...", "courseDescription": { "title": "Enter a Course Description", "placeholder": "Please enter a course description", @@ -86,7 +86,7 @@ "regenerateTooltip": "Generate additional Competency Recommendations", "infoTooltip": "Enter a course description, focusing on the theoretical/practical content and learning objectives. Iris will then use it to generate recommendations for competencies you could add to your course.", "success": "Generated {{ noOfCompetencies }} competency recommendations.", - "warning": "No competency recommendations could be generated. Try using a more detailed course description. If the issue persists, contact the administrators." + "warning": "No competency recommendations could be generated. If the issue persists, contact the administrators." } }, "importAll": { diff --git a/src/main/webapp/i18n/en/exercise-actions.json b/src/main/webapp/i18n/en/exercise-actions.json index b32d1e5be4d1..ba4ac935244d 100644 --- a/src/main/webapp/i18n/en/exercise-actions.json +++ b/src/main/webapp/i18n/en/exercise-actions.json @@ -70,6 +70,8 @@ "uploadFile": "Upload a file", "viewTeam": "Team", "sshKeyTip": "To use ssh, you need to add an ssh key to your account {link:here}.", + "vcsTokenTip": "To access the repository with a VCS access token, you need to add a new one to your account {link:here}.", + "vcsTokenExpiredTip": "Your VCS access token has expired. Renew it {link:here}.", "startExerciseBeforeStartDate": "You cannot participate before the start date of the exercise.", "deleteMultipleExercisesQuestion": "Are you sure you want to delete the selected exercises?" } diff --git a/src/main/webapp/i18n/en/exercise.json b/src/main/webapp/i18n/en/exercise.json index fbc47e4093dc..ca2f0fdde23c 100644 --- a/src/main/webapp/i18n/en/exercise.json +++ b/src/main/webapp/i18n/en/exercise.json @@ -140,6 +140,7 @@ }, "categories": "Categories", "noCategory": "No Category", + "selectCategories": "Select categories", "participation": "Participation", "participations": "Participations", "submissions": "Submissions", diff --git a/src/main/webapp/i18n/en/guidedTourProgrammingExercise.json b/src/main/webapp/i18n/en/guidedTourProgrammingExercise.json index 645864117bf9..9e23b5f35836 100644 --- a/src/main/webapp/i18n/en/guidedTourProgrammingExercise.json +++ b/src/main/webapp/i18n/en/guidedTourProgrammingExercise.json @@ -10,7 +10,7 @@ "headline": "Class file", "content": "Click on the class file to display its source code in the editor." }, - "aceEditor": { + "monacoEditor": { "headline": "Code editor", "content": "The source code is now displayed in the code editor. You can read through the TODO and try to solve it. Alternatively, you can enter any code for this test round. If you are done with your implementation, just click on the Next button to proceed." }, diff --git a/src/main/webapp/i18n/en/student-dashboard.json b/src/main/webapp/i18n/en/student-dashboard.json index 586cdf840ce1..ae6d54800cd6 100644 --- a/src/main/webapp/i18n/en/student-dashboard.json +++ b/src/main/webapp/i18n/en/student-dashboard.json @@ -33,7 +33,8 @@ "manage": "Manage", "studentView": "Student view", "general": { - "noDataFound": "No data found." + "noDataFound": "No data found.", + "noElementFoundWithAppliedFilter": "No element matches the applied filter." }, "sidebar": { "past": "Past", @@ -89,6 +90,17 @@ "plagiarismCases": "Plagiarism Cases", "gradingSystem": "Grading System" }, + "exerciseFilter": { + "filter": "Filter", + "modalTitle": "Filter Exercises", + "dueDateRange": "Due Date Range", + "achievedScore": "Achieved Score", + "achievablePoints": "Achievable Points", + "applyFilter": "Apply filter", + "clearFilter": "Clear filter", + "noFilterAvailable": "There are no distinguishing filter options for the existing exercises", + "noMoreOptions": "No more options" + }, "exerciseList": { "filter": "Filter ({{ num }})", "releaseDate": "Release Date", diff --git a/src/main/webapp/i18n/en/userSettings.json b/src/main/webapp/i18n/en/userSettings.json index 839dd249b260..4230ddab9506 100644 --- a/src/main/webapp/i18n/en/userSettings.json +++ b/src/main/webapp/i18n/en/userSettings.json @@ -5,7 +5,8 @@ "saveChanges": "Save Changes", "saveSettingsSuccessAlert": "Your Settings have been successfully saved.", "userSettings": "User Settings", - "sshSettings": "SSH Settings", + "sshSettings": "SSH", + "vcsAccessTokenSettings": "VCS Token", "accountInformation": "Account Information", "notificationSettings": "Notifications", "notificationSettingsFilterInfo": "These settings also filter the notification sidebar", @@ -27,6 +28,21 @@ "sshKeyDisplayedInformation": "This is your currently configured SSH key:", "key": "SSH Key" }, + "vcsAccessTokensSettingsPage": { + "addTokenTitle": "Add personal access token", + "infoText": "You can generate a personal access token to interact with the Artemis Local Version Control System. You can use it to authenticate to Git over HTTP.", + "deleteVcsAccessTokenQuestion": "Do you really want to delete your version control system access token? You will not be able to authenticate to local repositories cloned with this token any more.", + "createAccessToken": "Create new VCS access token", + "noTokenSet": "You do not have any user tokens", + "addToken": "Add new token", + "deleteFailure": "Deleting the VCS access token failed", + "deleteSuccess": "Successfully deleted the VCS access token", + "addFailure": "Failed to set new VCS access token", + "addSuccess": "Successfully created a new VCS access token", + "vcsAccessToken": "VCS Access Token", + "expiryDate": "Expiry Date", + "actions": "Actions" + }, "accountInformationPage": { "registrationNumber": "Registration Number", "fullName": "Full Name", @@ -38,7 +54,8 @@ "categories": { "NOTIFICATION_SETTINGS": "Notification Settings", "SCIENCE_SETTINGS": "Science Settings", - "SSH_SETTINGS": "SSH" + "SSH_SETTINGS": "SSH Settings", + "VCS_TOKEN_SETTINGS": "VCS Token Settings" }, "settingGroupNames": { "weeklySummary": "Weekly Summary", diff --git a/src/test/java/de/tum/in/www1/artemis/authentication/UserLocalVcIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/authentication/UserAccountLocalVcsIntegrationTest.java similarity index 64% rename from src/test/java/de/tum/in/www1/artemis/authentication/UserLocalVcIntegrationTest.java rename to src/test/java/de/tum/in/www1/artemis/authentication/UserAccountLocalVcsIntegrationTest.java index b419cbf57260..29630b917676 100644 --- a/src/test/java/de/tum/in/www1/artemis/authentication/UserLocalVcIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/authentication/UserAccountLocalVcsIntegrationTest.java @@ -9,7 +9,7 @@ import de.tum.in.www1.artemis.AbstractSpringIntegrationLocalCILocalVCTest; import de.tum.in.www1.artemis.user.UserTestService; -class UserLocalVcIntegrationTest extends AbstractSpringIntegrationLocalCILocalVCTest { +class UserAccountLocalVcsIntegrationTest extends AbstractSpringIntegrationLocalCILocalVCTest { private static final String TEST_PREFIX = "userlvc"; // shorter prefix as user's name is limited to 50 chars @@ -31,4 +31,16 @@ void teardown() throws Exception { void addAndDeleteSshPublicKeyByUser() throws Exception { userTestService.addAndDeleteSshPublicKey(); } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void getAndCreateParticipationVcsAccessTokenByUser() throws Exception { + userTestService.getAndCreateParticipationVcsAccessToken(); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void createAndDeleteUserVcsAccessTokenByUser() throws Exception { + userTestService.createAndDeleteUserVcsAccessToken(); + } } diff --git a/src/test/java/de/tum/in/www1/artemis/connector/IrisRequestMockProvider.java b/src/test/java/de/tum/in/www1/artemis/connector/IrisRequestMockProvider.java index 98f3ad4db3eb..3f248a9e598c 100644 --- a/src/test/java/de/tum/in/www1/artemis/connector/IrisRequestMockProvider.java +++ b/src/test/java/de/tum/in/www1/artemis/connector/IrisRequestMockProvider.java @@ -30,6 +30,7 @@ import de.tum.in.www1.artemis.service.connectors.pyris.dto.PyrisHealthStatusDTO; import de.tum.in.www1.artemis.service.connectors.pyris.dto.PyrisModelDTO; import de.tum.in.www1.artemis.service.connectors.pyris.dto.chat.exercise.PyrisExerciseChatPipelineExecutionDTO; +import de.tum.in.www1.artemis.service.connectors.pyris.dto.competency.PyrisCompetencyExtractionPipelineExecutionDTO; import de.tum.in.www1.artemis.service.connectors.pyris.dto.lectureingestionwebhook.PyrisWebhookLectureIngestionExecutionDTO; @Component @@ -96,6 +97,20 @@ public void mockRunResponse(Consumer resp // @formatter:on } + public void mockRunCompetencyExtractionResponseAnd(Consumer executionDTOConsumer) { + // @formatter:off + mockServer + .expect(ExpectedCount.once(), requestTo(pipelinesApiURL + "/competency-extraction/default/run")) + .andExpect(method(HttpMethod.POST)) + .andRespond(request -> { + var mockRequest = (MockClientHttpRequest) request; + var dto = mapper.readValue(mockRequest.getBodyAsString(), PyrisCompetencyExtractionPipelineExecutionDTO.class); + executionDTOConsumer.accept(dto); + return MockRestResponseCreators.withRawStatus(HttpStatus.ACCEPTED.value()).createResponse(request); + }); + // @formatter:on + } + public void mockIngestionWebhookRunResponse(Consumer responseConsumer) { mockServer.expect(ExpectedCount.once(), requestTo(webhooksApiURL + "/lectures/fullIngestion")).andExpect(method(HttpMethod.POST)).andRespond(request -> { var mockRequest = (MockClientHttpRequest) request; diff --git a/src/test/java/de/tum/in/www1/artemis/exam/ExamStartTest.java b/src/test/java/de/tum/in/www1/artemis/exam/ExamStartTest.java index 3086bb22234e..5181383dbf1f 100644 --- a/src/test/java/de/tum/in/www1/artemis/exam/ExamStartTest.java +++ b/src/test/java/de/tum/in/www1/artemis/exam/ExamStartTest.java @@ -36,7 +36,6 @@ import de.tum.in.www1.artemis.domain.User; import de.tum.in.www1.artemis.domain.enumeration.DiagramType; import de.tum.in.www1.artemis.domain.exam.Exam; -import de.tum.in.www1.artemis.domain.exam.ExamUser; import de.tum.in.www1.artemis.domain.exam.ExerciseGroup; import de.tum.in.www1.artemis.domain.exam.StudentExam; import de.tum.in.www1.artemis.domain.modeling.ModelingExercise; @@ -124,7 +123,6 @@ void initTestCase() throws GitAPIException { User student1 = userUtilService.getUserByLogin(TEST_PREFIX + "student1"); User student2 = userUtilService.getUserByLogin(TEST_PREFIX + "student2"); registeredUsers = Set.of(student1, student2); - exam.setExamUsers(Set.of(new ExamUser())); // setting dates exam.setStartDate(ZonedDateTime.now().plusHours(2)); exam.setEndDate(ZonedDateTime.now().plusHours(3)); @@ -137,6 +135,7 @@ void tearDown() throws Exception { programmingExerciseTestService.tearDown(); } + // TODO: why do we remove the student exams here? This is not really necessary // Cleanup of Bidirectional Relationships for (StudentExam studentExam : createdStudentExams) { exam.removeStudentExam(studentExam); diff --git a/src/test/java/de/tum/in/www1/artemis/iris/AbstractIrisIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/iris/AbstractIrisIntegrationTest.java index e7910012719e..dba05fa055cd 100644 --- a/src/test/java/de/tum/in/www1/artemis/iris/AbstractIrisIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/iris/AbstractIrisIntegrationTest.java @@ -21,7 +21,6 @@ import de.tum.in.www1.artemis.domain.Course; import de.tum.in.www1.artemis.domain.ProgrammingExercise; import de.tum.in.www1.artemis.domain.iris.IrisTemplate; -import de.tum.in.www1.artemis.domain.iris.session.IrisExerciseChatSession; import de.tum.in.www1.artemis.domain.iris.settings.IrisSubSettings; import de.tum.in.www1.artemis.exercise.programming.ProgrammingExerciseUtilService; import de.tum.in.www1.artemis.repository.ProgrammingExerciseRepository; @@ -111,15 +110,13 @@ protected IrisTemplate createDummyTemplate() { } /** - * Verify that the given messages were sent through the websocket for the given chat session, - * and that there were exactly `matchers.length` messages sent. + * Verify that the given messages were sent through the websocket for the given user and topic. * - * @param session The chat session - * @param matchers Argument matchers which describe the messages that should have been sent + * @param userLogin The user login + * @param topicSuffix The chat session + * @param matchers Argument matchers which describe the messages that should have been sent */ - protected void verifyWebsocketActivityWasExactly(IrisExerciseChatSession session, ArgumentMatcher... matchers) { - var userLogin = session.getUser().getLogin(); - var topicSuffix = "" + session.getId(); + protected void verifyWebsocketActivityWasExactly(String userLogin, String topicSuffix, ArgumentMatcher... matchers) { for (ArgumentMatcher callDescriptor : matchers) { verifyMessageWasSentOverWebsocket(userLogin, topicSuffix, callDescriptor); } @@ -133,7 +130,7 @@ protected void verifyWebsocketActivityWasExactly(IrisExerciseChatSession session * @param topicSuffix The topic suffix, e.g. "sessions/123" * @param matcher Argument matcher which describes the message that should have been sent */ - private void verifyMessageWasSentOverWebsocket(String userLogin, String topicSuffix, ArgumentMatcher matcher) { + protected void verifyMessageWasSentOverWebsocket(String userLogin, String topicSuffix, ArgumentMatcher matcher) { // @formatter:off verify(websocketMessagingService, timeout(TIMEOUT_MS).times(1)) .sendMessageToUser( @@ -147,7 +144,7 @@ private void verifyMessageWasSentOverWebsocket(String userLogin, String topicSuf /** * Verify that exactly `numberOfCalls` messages were sent through the websocket for the given user and topic. */ - private void verifyNumberOfCallsToWebsocket(String userLogin, String topicSuffix, int numberOfCalls) { + protected void verifyNumberOfCallsToWebsocket(String userLogin, String topicSuffix, int numberOfCalls) { // @formatter:off verify(websocketMessagingService, times(numberOfCalls)) .sendMessageToUser( diff --git a/src/test/java/de/tum/in/www1/artemis/iris/IrisChatMessageIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/iris/IrisChatMessageIntegrationTest.java index 13297c1f2cb1..aefc058fee7f 100644 --- a/src/test/java/de/tum/in/www1/artemis/iris/IrisChatMessageIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/iris/IrisChatMessageIntegrationTest.java @@ -48,8 +48,8 @@ import de.tum.in.www1.artemis.service.connectors.pyris.dto.status.PyrisStageDTO; import de.tum.in.www1.artemis.service.connectors.pyris.dto.status.PyrisStageState; import de.tum.in.www1.artemis.service.iris.IrisMessageService; +import de.tum.in.www1.artemis.service.iris.dto.IrisChatWebsocketDTO; import de.tum.in.www1.artemis.service.iris.session.IrisExerciseChatSessionService; -import de.tum.in.www1.artemis.service.iris.websocket.IrisWebsocketDTO; class IrisChatMessageIntegrationTest extends AbstractIrisIntegrationTest { @@ -139,8 +139,8 @@ void sendOneMessage() throws Exception { await().until(pipelineDone::get); - verifyWebsocketActivityWasExactly(irisSession, messageDTO(messageToSend.getContent()), statusDTO(IN_PROGRESS, NOT_STARTED), statusDTO(DONE, IN_PROGRESS), - messageDTO("Hello World")); + verifyWebsocketActivityWasExactly(irisSession.getUser().getLogin(), String.valueOf(irisSession.getId()), messageDTO(messageToSend.getContent()), + statusDTO(IN_PROGRESS, NOT_STARTED), statusDTO(DONE, IN_PROGRESS), messageDTO("Hello World")); } @Test @@ -164,8 +164,8 @@ void sendSuggestions() throws Exception { await().until(pipelineDone::get); - verifyWebsocketActivityWasExactly(irisSession, messageDTO(messageToSend.getContent()), statusDTO(IN_PROGRESS, NOT_STARTED), statusDTO(DONE, IN_PROGRESS), - suggestionsDTO("suggestion1", "suggestion2", "suggestion3")); + verifyWebsocketActivityWasExactly(irisSession.getUser().getLogin(), String.valueOf(irisSession.getId()), messageDTO(messageToSend.getContent()), + statusDTO(IN_PROGRESS, NOT_STARTED), statusDTO(DONE, IN_PROGRESS), suggestionsDTO("suggestion1", "suggestion2", "suggestion3")); } @Test @@ -306,7 +306,8 @@ void resendMessage() throws Exception { var irisMessage = irisMessageService.saveMessage(messageToSend, irisSession, IrisMessageSender.USER); request.postWithoutResponseBody("/api/iris/sessions/" + irisSession.getId() + "/messages/" + irisMessage.getId() + "/resend", null, HttpStatus.OK); await().until(() -> irisSessionRepository.findByIdWithMessagesElseThrow(irisSession.getId()).getMessages().size() == 2); - verifyWebsocketActivityWasExactly(irisSession, statusDTO(IN_PROGRESS, NOT_STARTED), statusDTO(DONE, IN_PROGRESS), messageDTO("Hello World")); + verifyWebsocketActivityWasExactly(irisSession.getUser().getLogin(), String.valueOf(irisSession.getId()), statusDTO(IN_PROGRESS, NOT_STARTED), statusDTO(DONE, IN_PROGRESS), + messageDTO("Hello World")); } // User needs to be Admin to change settings @@ -337,8 +338,8 @@ void sendMessageRateLimitReached() throws Exception { var irisMessage = irisMessageService.saveMessage(messageToSend2, irisSession, IrisMessageSender.USER); request.postWithoutResponseBody("/api/iris/sessions/" + irisSession.getId() + "/messages/" + irisMessage.getId() + "/resend", null, HttpStatus.TOO_MANY_REQUESTS); - verifyWebsocketActivityWasExactly(irisSession, messageDTO(messageToSend1.getContent()), statusDTO(IN_PROGRESS, NOT_STARTED), statusDTO(DONE, IN_PROGRESS), - messageDTO("Hello World")); + verifyWebsocketActivityWasExactly(irisSession.getUser().getLogin(), String.valueOf(irisSession.getId()), messageDTO(messageToSend1.getContent()), + statusDTO(IN_PROGRESS, NOT_STARTED), statusDTO(DONE, IN_PROGRESS), messageDTO("Hello World")); } finally { // Reset to not interfere with other tests @@ -375,10 +376,10 @@ private ArgumentMatcher messageDTO(List content) { @Override public boolean matches(Object argument) { - if (!(argument instanceof IrisWebsocketDTO websocketDTO)) { + if (!(argument instanceof IrisChatWebsocketDTO websocketDTO)) { return false; } - if (websocketDTO.type() != IrisWebsocketDTO.IrisWebsocketMessageType.MESSAGE) { + if (websocketDTO.type() != IrisChatWebsocketDTO.IrisWebsocketMessageType.MESSAGE) { return false; } return Objects.equals(websocketDTO.message().getContent().stream().map(IrisMessageContent::getContentAsString).toList(), @@ -397,10 +398,10 @@ private ArgumentMatcher statusDTO(PyrisStageState... stageStates) { @Override public boolean matches(Object argument) { - if (!(argument instanceof IrisWebsocketDTO websocketDTO)) { + if (!(argument instanceof IrisChatWebsocketDTO websocketDTO)) { return false; } - if (websocketDTO.type() != IrisWebsocketDTO.IrisWebsocketMessageType.STATUS) { + if (websocketDTO.type() != IrisChatWebsocketDTO.IrisWebsocketMessageType.STATUS) { return false; } if (websocketDTO.stages() == null) { @@ -424,10 +425,10 @@ private ArgumentMatcher suggestionsDTO(String... suggestions) { @Override public boolean matches(Object argument) { - if (!(argument instanceof IrisWebsocketDTO websocketDTO)) { + if (!(argument instanceof IrisChatWebsocketDTO websocketDTO)) { return false; } - if (websocketDTO.type() != IrisWebsocketDTO.IrisWebsocketMessageType.STATUS) { + if (websocketDTO.type() != IrisChatWebsocketDTO.IrisWebsocketMessageType.STATUS) { return false; } if (websocketDTO.suggestions() == null) { diff --git a/src/test/java/de/tum/in/www1/artemis/iris/IrisChatWebsocketTest.java b/src/test/java/de/tum/in/www1/artemis/iris/IrisChatWebsocketTest.java index 0a1bb785ab2d..4ce540f175c8 100644 --- a/src/test/java/de/tum/in/www1/artemis/iris/IrisChatWebsocketTest.java +++ b/src/test/java/de/tum/in/www1/artemis/iris/IrisChatWebsocketTest.java @@ -18,9 +18,9 @@ import de.tum.in.www1.artemis.domain.iris.message.IrisTextMessageContent; import de.tum.in.www1.artemis.service.WebsocketMessagingService; import de.tum.in.www1.artemis.service.iris.IrisRateLimitService; +import de.tum.in.www1.artemis.service.iris.dto.IrisChatWebsocketDTO; import de.tum.in.www1.artemis.service.iris.session.IrisExerciseChatSessionService; import de.tum.in.www1.artemis.service.iris.websocket.IrisChatWebsocketService; -import de.tum.in.www1.artemis.service.iris.websocket.IrisWebsocketDTO; @ActiveProfiles("iris") class IrisChatWebsocketTest extends AbstractIrisIntegrationTest { @@ -53,9 +53,9 @@ void sendMessage() { var message = irisSession.newMessage(); message.addContent(createMockContent(), createMockContent()); message.setMessageDifferentiator(101010); - irisChatWebsocketService.sendMessage(message, List.of()); + irisChatWebsocketService.sendMessage(irisSession, message, List.of()); verify(websocketMessagingService, times(1)).sendMessageToUser(eq(TEST_PREFIX + "student1"), eq("/topic/iris/" + irisSession.getId()), - eq(new IrisWebsocketDTO(message, new IrisRateLimitService.IrisRateLimitInformation(0, -1, 0), List.of(), List.of()))); + eq(new IrisChatWebsocketDTO(message, new IrisRateLimitService.IrisRateLimitInformation(0, -1, 0), List.of(), List.of()))); } private IrisTextMessageContent createMockContent() { diff --git a/src/test/java/de/tum/in/www1/artemis/iris/IrisCompetencyGenerationIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/iris/IrisCompetencyGenerationIntegrationTest.java index 613b35f5d08f..e67f52342bfc 100644 --- a/src/test/java/de/tum/in/www1/artemis/iris/IrisCompetencyGenerationIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/iris/IrisCompetencyGenerationIntegrationTest.java @@ -1,24 +1,35 @@ package de.tum.in.www1.artemis.iris; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.fail; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; import java.util.List; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.security.test.context.support.WithMockUser; import de.tum.in.www1.artemis.domain.Course; -import de.tum.in.www1.artemis.domain.competency.Competency; import de.tum.in.www1.artemis.domain.competency.CompetencyTaxonomy; +import de.tum.in.www1.artemis.service.connectors.pyris.dto.competency.PyrisCompetencyExtractionInputDTO; +import de.tum.in.www1.artemis.service.connectors.pyris.dto.competency.PyrisCompetencyRecommendationDTO; +import de.tum.in.www1.artemis.service.connectors.pyris.dto.competency.PyrisCompetencyStatusUpdateDTO; +import de.tum.in.www1.artemis.service.connectors.pyris.dto.status.PyrisStageDTO; +import de.tum.in.www1.artemis.service.connectors.pyris.dto.status.PyrisStageState; +import de.tum.in.www1.artemis.service.iris.IrisCompetencyGenerationService; class IrisCompetencyGenerationIntegrationTest extends AbstractIrisIntegrationTest { private static final String TEST_PREFIX = "iriscompetencyintegration"; + @Autowired + IrisCompetencyGenerationService irisCompetencyGenerationService; + private Course course; @BeforeEach @@ -31,34 +42,43 @@ void initTestCase() { } @Test - @Disabled // TODO: Enable this test again! @WithMockUser(username = TEST_PREFIX + "editor1", roles = "EDITOR") void generateCompetencies_asEditor_shouldSucceed() throws Exception { - final String courseDescription = "Any description"; - Competency expected = new Competency(); - expected.setTitle("title"); - expected.setDescription("description"); - expected.setTaxonomy(CompetencyTaxonomy.ANALYZE); - // var competencyMap1 = Map.of("title", expected.getTitle(), "description", expected.getDescription(), "taxonomy", expected.getTaxonomy()); - // // empty or malformed competencies are ignored - // var competencyMap2 = Map.of("title", "!done"); - // var competencyMap3 = Map.of("malformed", "any content"); - // var responseMap = Map.of("competencies", List.of(competencyMap1, competencyMap2, competencyMap3)); - - /* - * irisRequestMockProvider.mockRunResponse(dto -> { - * assertThat(dto.settings().authenticationToken()).isNotNull(); - * pipelineDone.set(true); - * }); - */ - fail("This test is not yet implemented. Implement it and remove the fail call."); - - List competencies = request.postListWithResponseBody("/api/courses/" + course.getId() + "/course-competencies/generate-from-description", courseDescription, - Competency.class, HttpStatus.OK); - Competency actualCompetency = competencies.getFirst(); - - assertThat(competencies.size()).isEqualTo(1); - assertThat(actualCompetency).usingRecursiveComparison().comparingOnlyFields("title", "description", "taxonomy").isEqualTo(expected); + String courseDescription = "Cool course description"; + var currentCompetencies = new PyrisCompetencyRecommendationDTO[] { new PyrisCompetencyRecommendationDTO("test title", "test description", CompetencyTaxonomy.UNDERSTAND), }; + + // Expect that a request is sent to Pyris having the following characteristics + irisRequestMockProvider.mockRunCompetencyExtractionResponseAnd(dto -> { + var token = dto.execution().settings().authenticationToken(); + assertThat(token).isNotNull(); + assertThat(dto.courseDescription()).contains(courseDescription); + assertThat(dto.currentCompetencies()).containsExactly(currentCompetencies); + assertThat(dto.taxonomyOptions()).isNotEmpty(); + assertThat(dto.maxN()).isPositive(); + }); + + // Send a request to the Artemis server as if the user had clicked the button in the UI + request.postWithoutResponseBody("/api/courses/" + course.getId() + "/course-competencies/generate-from-description", + new PyrisCompetencyExtractionInputDTO(courseDescription, currentCompetencies), HttpStatus.ACCEPTED); + + PyrisCompetencyRecommendationDTO expected = new PyrisCompetencyRecommendationDTO("test title", "test description", CompetencyTaxonomy.UNDERSTAND); + List recommendations = List.of(expected, expected, expected); + List stages = List.of(new PyrisStageDTO("Generating Competencies", 10, PyrisStageState.DONE, null)); + + // In the real system, this would be triggered by Pyris via a REST call to the Artemis server + irisCompetencyGenerationService.handleStatusUpdate(TEST_PREFIX + "editor1", course.getId(), new PyrisCompetencyStatusUpdateDTO(stages, recommendations)); + + ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(PyrisCompetencyStatusUpdateDTO.class); + verify(websocketMessagingService, timeout(200).times(3)).sendMessageToUser(eq(TEST_PREFIX + "editor1"), eq("/topic/iris/competencies/" + course.getId()), + argumentCaptor.capture()); + + List allValues = argumentCaptor.getAllValues(); + assertThat(allValues.get(0).stages()).hasSize(2); + assertThat(allValues.get(0).result()).isNull(); + assertThat(allValues.get(1).stages()).hasSize(2); + assertThat(allValues.get(1).result()).isNull(); + assertThat(allValues.get(2).stages()).hasSize(1); + assertThat(allValues.get(2).result()).isEqualTo(recommendations); } @Test @@ -74,6 +94,7 @@ void testAll_asTutor_shouldReturnForbidden() throws Exception { } void testAllPreAuthorize() throws Exception { - request.post("/api/courses/" + course.getId() + "/course-competencies/generate-from-description", "a", HttpStatus.FORBIDDEN); + request.post("/api/courses/" + course.getId() + "/course-competencies/generate-from-description", + new PyrisCompetencyExtractionInputDTO("a", new PyrisCompetencyRecommendationDTO[] {}), HttpStatus.FORBIDDEN); } } diff --git a/src/test/java/de/tum/in/www1/artemis/localvcci/LocalVCLocalCIParticipationIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/localvcci/LocalVCLocalCIParticipationIntegrationTest.java index 190d02485212..cbd957d51dd1 100644 --- a/src/test/java/de/tum/in/www1/artemis/localvcci/LocalVCLocalCIParticipationIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/localvcci/LocalVCLocalCIParticipationIntegrationTest.java @@ -57,7 +57,7 @@ void testStartParticipation() throws Exception { LocalVCRepositoryUri studentAssignmentRepositoryUri = new LocalVCRepositoryUri(projectKey, projectKey.toLowerCase() + "-" + TEST_PREFIX + "student1", localVCBaseUrl); assertThat(studentAssignmentRepositoryUri.getLocalRepositoryPath(localVCBasePath)).exists(); - var vcsAccessToken = request.get("/api/users/vcsToken?participationId=" + participation.getId(), HttpStatus.OK, String.class); + var vcsAccessToken = request.get("/api/account/participation-vcs-access-token?participationId=" + participation.getId(), HttpStatus.OK, String.class); assertThat(vcsAccessToken).isNotNull(); assertThat(vcsAccessToken).startsWith("vcpat"); diff --git a/src/test/java/de/tum/in/www1/artemis/user/UserTestService.java b/src/test/java/de/tum/in/www1/artemis/user/UserTestService.java index 394200342127..7902d3348d51 100644 --- a/src/test/java/de/tum/in/www1/artemis/user/UserTestService.java +++ b/src/test/java/de/tum/in/www1/artemis/user/UserTestService.java @@ -27,13 +27,18 @@ import de.tum.in.www1.artemis.course.CourseUtilService; import de.tum.in.www1.artemis.domain.Authority; import de.tum.in.www1.artemis.domain.Course; +import de.tum.in.www1.artemis.domain.ProgrammingSubmission; import de.tum.in.www1.artemis.domain.User; +import de.tum.in.www1.artemis.domain.enumeration.SubmissionType; import de.tum.in.www1.artemis.domain.science.ScienceEvent; import de.tum.in.www1.artemis.domain.science.ScienceEventType; import de.tum.in.www1.artemis.exercise.programming.MockDelegate; import de.tum.in.www1.artemis.exercise.programming.ProgrammingExerciseUtilService; import de.tum.in.www1.artemis.repository.AuthorityRepository; import de.tum.in.www1.artemis.repository.CourseRepository; +import de.tum.in.www1.artemis.repository.ParticipationRepository; +import de.tum.in.www1.artemis.repository.ParticipationVCSAccessTokenRepository; +import de.tum.in.www1.artemis.repository.SubmissionRepository; import de.tum.in.www1.artemis.repository.UserRepository; import de.tum.in.www1.artemis.repository.science.ScienceEventRepository; import de.tum.in.www1.artemis.security.Role; @@ -106,6 +111,15 @@ public class UserTestService { private static final int NUMBER_OF_INSTRUCTORS = 1; + @Autowired + private ParticipationVCSAccessTokenRepository participationVCSAccessTokenRepository; + + @Autowired + private ParticipationRepository participationRepository; + + @Autowired + private SubmissionRepository submissionRepository; + public void setup(String testPrefix, MockDelegate mockDelegate) throws Exception { this.TEST_PREFIX = testPrefix; this.mockDelegate = mockDelegate; @@ -817,18 +831,73 @@ public void addAndDeleteSshPublicKey() throws Exception { // adding invalid key should fail String invalidSshKey = "invalid key"; - request.putWithResponseBody("/api/users/sshpublickey", invalidSshKey, String.class, HttpStatus.BAD_REQUEST, true); + request.putWithResponseBody("/api/account/ssh-public-key", invalidSshKey, String.class, HttpStatus.BAD_REQUEST, true); // adding valid key should work correctly String validSshKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEbgjoSpKnry5yuMiWh/uwhMG2Jq5Sh8Uw9vz+39or2i email@abc.de"; - request.putWithResponseBody("/api/users/sshpublickey", validSshKey, String.class, HttpStatus.OK, true); + request.putWithResponseBody("/api/account/ssh-public-key", validSshKey, String.class, HttpStatus.OK, true); assertThat(userRepository.getUser().getSshPublicKey()).isEqualTo(validSshKey); // deleting the key shoul work correctly - request.delete("/api/users/sshpublickey", HttpStatus.OK); + request.delete("/api/account/ssh-public-key", HttpStatus.OK); assertThat(userRepository.getUser().getSshPublicKey()).isEqualTo(null); } + // Test + public void getAndCreateParticipationVcsAccessToken() throws Exception { + User user = userUtilService.getUserByLogin(TEST_PREFIX + "student1"); + + // try to get token for non existent participation + request.get("/api/account/participation-vcs-access-token?participationId=11", HttpStatus.NOT_FOUND, String.class); + + var course = courseUtilService.addEmptyCourse(); + var exercise = programmingExerciseUtilService.addProgrammingExerciseToCourse(course); + courseRepository.save(course); + + var submission = (ProgrammingSubmission) new ProgrammingSubmission().commitHash("abc").type(SubmissionType.MANUAL).submitted(true); + submission = programmingExerciseUtilService.addProgrammingSubmission(exercise, submission, user.getLogin()); + // request existing token + var token = request.get("/api/account/participation-vcs-access-token?participationId=" + submission.getParticipation().getId(), HttpStatus.OK, String.class); + assertThat(token).isNotNull(); + + // delete all tokens + participationVCSAccessTokenRepository.deleteAll(); + + // check that token was deleted + request.get("/api/account/participation-vcs-access-token?participationId=" + submission.getParticipation().getId(), HttpStatus.NOT_FOUND, String.class); + var newToken = request.putWithResponseBody("/api/account/participation-vcs-access-token?participationId=" + submission.getParticipation().getId(), null, String.class, + HttpStatus.OK); + assertThat(newToken).isNotEqualTo(token); + + submissionRepository.delete(submission); + participationVCSAccessTokenRepository.deleteAll(); + participationRepository.deleteById(submission.getParticipation().getId()); + } + + // Test + public void createAndDeleteUserVcsAccessToken() throws Exception { + User user = userUtilService.getUserByLogin(TEST_PREFIX + "student1"); + assertThat(user.getVcsAccessToken()).isNull(); + + // Set expiry date to already past date -> Bad Request + ZonedDateTime expiryDate = ZonedDateTime.now().minusMonths(1); + var userDTO = request.putWithResponseBody("/api/account/user-vcs-access-token?expiryDate=" + expiryDate, null, UserDTO.class, HttpStatus.BAD_REQUEST); + assertThat(userDTO).isNull(); + + // Correct expiry date -> OK + expiryDate = ZonedDateTime.now().plusMonths(1); + userDTO = request.putWithResponseBody("/api/account/user-vcs-access-token?expiryDate=" + expiryDate, null, UserDTO.class, HttpStatus.OK); + user = userUtilService.getUserByLogin(TEST_PREFIX + "student1"); + assertThat(user.getVcsAccessToken()).isEqualTo(userDTO.getVcsAccessToken()); + assertThat(user.getVcsAccessTokenExpiryDate()).isEqualTo(userDTO.getVcsAccessTokenExpiryDate()); + + // Delete token + request.delete("/api/account/user-vcs-access-token", HttpStatus.OK); + user = userUtilService.getUserByLogin(TEST_PREFIX + "student1"); + assertThat(user.getVcsAccessToken()).isNull(); + assertThat(user.getVcsAccessTokenExpiryDate()).isNull(); + } + public UserRepository getUserRepository() { return userRepository; } diff --git a/src/test/javascript/spec/component/account/vcs-access-token-settings.component.spec.ts b/src/test/javascript/spec/component/account/vcs-access-token-settings.component.spec.ts new file mode 100644 index 000000000000..71439b28cfba --- /dev/null +++ b/src/test/javascript/spec/component/account/vcs-access-token-settings.component.spec.ts @@ -0,0 +1,187 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { AccountService } from 'app/core/auth/account.service'; +import { of, throwError } from 'rxjs'; +import { By } from '@angular/platform-browser'; +import { MockComponent, MockPipe } from 'ng-mocks'; +import { ButtonComponent } from 'app/shared/components/button.component'; +import { FormDateTimePickerComponent } from 'app/shared/date-time-picker/date-time-picker.component'; +import { User } from 'app/core/user/user.model'; +import { ArtemisTestModule } from '../../test.module'; +import { MockNgbModalService } from '../../helpers/mocks/service/mock-ngb-modal.service'; +import { MockTranslateService, TranslatePipeMock } from '../../helpers/mocks/service/mock-translate.service'; +import { TranslateService } from '@ngx-translate/core'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { VcsAccessTokensSettingsComponent } from 'app/shared/user-settings/vcs-access-tokens-settings/vcs-access-tokens-settings.component'; +import dayjs from 'dayjs/esm'; +import { ArtemisDatePipe } from 'app/shared/pipes/artemis-date.pipe'; +import { AlertService } from 'app/core/util/alert.service'; + +describe('VcsAccessTokensSettingsComponent', () => { + let fixture: ComponentFixture; + let comp: VcsAccessTokensSettingsComponent; + + let accountServiceMock: { getAuthenticationState: jest.Mock; deleteUserVcsAccessToken: jest.Mock; addNewVcsAccessToken: jest.Mock }; + const alertServiceMock = { error: jest.fn(), addAlert: jest.fn() }; + let translateService: TranslateService; + + const token = 'initial-token'; + + beforeEach(async () => { + accountServiceMock = { + getAuthenticationState: jest.fn(), + deleteUserVcsAccessToken: jest.fn(), + addNewVcsAccessToken: jest.fn(), + }; + + await TestBed.configureTestingModule({ + imports: [ArtemisTestModule], + declarations: [ + VcsAccessTokensSettingsComponent, + TranslatePipeMock, + MockPipe(ArtemisDatePipe), + MockComponent(ButtonComponent), + MockComponent(FormDateTimePickerComponent), + ], + providers: [ + { provide: AccountService, useValue: accountServiceMock }, + { provide: TranslateService, useClass: MockTranslateService }, + { provide: NgbModal, useClass: MockNgbModalService }, + { provide: AlertService, useValue: alertServiceMock }, + ], + }).compileComponents(); + fixture = TestBed.createComponent(VcsAccessTokensSettingsComponent); + comp = fixture.componentInstance; + + translateService = TestBed.inject(TranslateService); + translateService.currentLang = 'en'; + + accountServiceMock.getAuthenticationState.mockReturnValue(of({ id: 1, vcsAccessToken: token, vcsAccessTokenExpiryDate: '11:20' } as User)); + accountServiceMock.addNewVcsAccessToken.mockReturnValue(of({ id: 1, vcsAccessToken: token, vcsAccessTokenExpiryDate: '11:20' } as User)); + accountServiceMock.deleteUserVcsAccessToken.mockReturnValue(of({})); + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('should cancel token creation', () => { + accountServiceMock.getAuthenticationState.mockReturnValue(of({ id: 1 } as User)); + + startTokenCreation(); + + // click button to send expiry date to server, to create the new token + const createTokenButton = fixture.debugElement.query(By.css('#cancel-vcs-token-creation-button')); + createTokenButton.triggerEventHandler('onClick', null); + fixture.detectChanges(); + expect(comp.edit).toBeFalsy(); + }); + + it('should fail token creation with invalid date', () => { + accountServiceMock.getAuthenticationState.mockReturnValue(of({ id: 1 } as User)); + startTokenCreation(); + + // add an invalid expiry date + comp.expiryDate = dayjs().subtract(7, 'day'); + comp.validateDate(); + fixture.detectChanges(); + + // click button to send expiry date to server, to create the new token + const createTokenButton = fixture.debugElement.query(By.css('#create-vcs-token-button')); + createTokenButton.triggerEventHandler('onClick', null); + fixture.detectChanges(); + expect(comp.edit).toBeTruthy(); + expect(comp.currentUser?.vcsAccessToken).toBeUndefined(); + expect(alertServiceMock.error).toHaveBeenCalled(); + }); + + it('should handle failed token creation', () => { + accountServiceMock.addNewVcsAccessToken.mockImplementation(() => { + return throwError(() => new Error('Internal Server error')); + }); + + accountServiceMock.getAuthenticationState.mockReturnValue(of({ id: 1 } as User)); + startTokenCreation(); + + // add an invalid expiry date + comp.expiryDate = dayjs().add(7, 'day'); + comp.validExpiryDate = true; + + // click button to send expiry date to server, to create the new token + const createTokenButton = fixture.debugElement.query(By.css('#create-vcs-token-button')); + createTokenButton.triggerEventHandler('onClick', null); + fixture.detectChanges(); + expect(comp.edit).toBeTruthy(); + expect(alertServiceMock.error).toHaveBeenCalled(); + }); + + it('should create new vcs access token', () => { + const newToken = 'new-token'; + const tokenExpiryDate = dayjs().add(7, 'day'); + + accountServiceMock.getAuthenticationState.mockReturnValue(of({ id: 1 } as User)); + accountServiceMock.addNewVcsAccessToken.mockReturnValue(of({ body: { id: 1, vcsAccessToken: newToken, vcsAccessTokenExpiryDate: tokenExpiryDate.toISOString() } as User })); + startTokenCreation(); + + // add an expiry date + comp.expiryDate = tokenExpiryDate; + comp.validateDate(); + fixture.detectChanges(); + + // click button to send expiry date to server, to create the new token + const createTokenButton = fixture.debugElement.query(By.css('#create-vcs-token-button')); + createTokenButton.triggerEventHandler('onClick', null); + fixture.detectChanges(); + + expect(comp.edit).toBeFalsy(); + expect(accountServiceMock.addNewVcsAccessToken).toHaveBeenCalled(); + expect(comp.currentUser!.vcsAccessToken).toEqual(newToken); + }); + + it('should delete vcs access token', () => { + accountServiceMock.deleteUserVcsAccessToken.mockImplementation(() => { + return throwError(() => new Error('Internal Server error')); + }); + comp.ngOnInit(); + comp.deleteVcsAccessToken(); + expect(accountServiceMock.deleteUserVcsAccessToken).toHaveBeenCalled(); + expect(alertServiceMock.error).toHaveBeenCalled(); + }); + + it('should handle error when delete vcs access token fails', () => { + const newToken = 'new-token'; + accountServiceMock.addNewVcsAccessToken.mockReturnValue(of({ id: 1, vcsAccessToken: newToken, vcsAccessTokenExpiryDate: '11:20' } as User)); + comp.ngOnInit(); + expect(comp.currentUser!.vcsAccessToken).toEqual(token); + comp.deleteVcsAccessToken(); + expect(accountServiceMock.deleteUserVcsAccessToken).toHaveBeenCalled(); + expect(comp.currentUser!.vcsAccessToken).toBeUndefined(); + }); + + it('should set wasCopied to true and back to false after 3 seconds on successful copy', () => { + comp.ngOnInit(); + + jest.useFakeTimers(); + comp.onCopyFinished(true); + expect(comp.wasCopied).toBeTruthy(); + jest.advanceTimersByTime(3000); + expect(comp.wasCopied).toBeFalsy(); + jest.useRealTimers(); + }); + + it('should not change wasCopied if copy is unsuccessful', () => { + comp.ngOnInit(); + comp.onCopyFinished(false); + + // Verify that wasCopied remains false + expect(comp.wasCopied).toBeFalsy(); + }); + + function startTokenCreation() { + comp.ngOnInit(); + fixture.detectChanges(); + expect(comp.currentUser!.vcsAccessToken).toBeUndefined(); + + // click on new token button + const addTokenButton = fixture.debugElement.query(By.css('#add-new-token-button')); + addTokenButton.triggerEventHandler('onClick', null); + fixture.detectChanges(); + expect(comp.edit).toBeTruthy(); + } +}); diff --git a/src/test/javascript/spec/component/code-editor/code-editor-actions.component.spec.ts b/src/test/javascript/spec/component/code-editor/code-editor-actions.component.spec.ts index 9b11c3e3948d..99c170f5720d 100644 --- a/src/test/javascript/spec/component/code-editor/code-editor-actions.component.spec.ts +++ b/src/test/javascript/spec/component/code-editor/code-editor-actions.component.spec.ts @@ -16,7 +16,6 @@ import { CommitState, EditorState } from 'app/exercises/programming/shared/code- import { MockModule } from 'ng-mocks'; import { TranslatePipeMock } from '../../helpers/mocks/service/mock-translate.service'; import { FeatureToggleDirective } from 'app/shared/feature-toggle/feature-toggle.directive'; -import { AceEditorModule } from 'app/shared/markdown-editor/ace-editor/ace-editor.module'; import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; // Cartesian product helper function @@ -50,7 +49,7 @@ describe('CodeEditorActionsComponent', () => { beforeEach(() => { TestBed.configureTestingModule({ - imports: [ArtemisTestModule, MockModule(AceEditorModule), MockModule(NgbTooltipModule)], + imports: [ArtemisTestModule, MockModule(NgbTooltipModule)], declarations: [CodeEditorActionsComponent, TranslatePipeMock, FeatureToggleDirective], providers: [ { provide: CodeEditorRepositoryService, useClass: MockCodeEditorRepositoryService }, diff --git a/src/test/javascript/spec/component/code-editor/code-editor-build-output.component.spec.ts b/src/test/javascript/spec/component/code-editor/code-editor-build-output.component.spec.ts index 3092d1c466ac..cca5d65fc88d 100644 --- a/src/test/javascript/spec/component/code-editor/code-editor-build-output.component.spec.ts +++ b/src/test/javascript/spec/component/code-editor/code-editor-build-output.component.spec.ts @@ -1,7 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { DebugElement } from '@angular/core'; -import { AceEditorModule } from 'app/shared/markdown-editor/ace-editor/ace-editor.module'; import { of } from 'rxjs'; import { ArtemisTestModule } from '../../test.module'; import { ParticipationWebsocketService } from 'app/overview/participation-websocket.service'; @@ -82,7 +81,7 @@ describe('CodeEditorBuildOutputComponent', () => { beforeEach(() => { TestBed.configureTestingModule({ - imports: [ArtemisTestModule, AceEditorModule], + imports: [ArtemisTestModule], declarations: [CodeEditorBuildOutputComponent, MockPipe(ArtemisDatePipe)], providers: [ { provide: ResultService, useClass: MockResultService }, diff --git a/src/test/javascript/spec/component/code-editor/code-editor-header.component.spec.ts b/src/test/javascript/spec/component/code-editor/code-editor-header.component.spec.ts index 6e221deca2fb..d77f3e5fb3cd 100644 --- a/src/test/javascript/spec/component/code-editor/code-editor-header.component.spec.ts +++ b/src/test/javascript/spec/component/code-editor/code-editor-header.component.spec.ts @@ -2,7 +2,8 @@ import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testin import { CodeEditorHeaderComponent } from 'app/exercises/programming/shared/code-editor/header/code-editor-header.component'; import { ArtemisTestModule } from '../../test.module'; import { NgbDropdownMocksModule } from '../../helpers/mocks/directive/ngbDropdownMocks.module'; -import { MAX_TAB_SIZE } from 'app/shared/markdown-editor/ace-editor/ace-editor.component'; + +import { MAX_TAB_SIZE } from 'app/shared/monaco-editor/monaco-editor.component'; describe('CodeEditorHeaderComponent', () => { let fixture: ComponentFixture; diff --git a/src/test/javascript/spec/component/code-editor/code-editor-monaco.component.spec.ts b/src/test/javascript/spec/component/code-editor/code-editor-monaco.component.spec.ts index 1acb8bc88d43..e6a35ac15468 100644 --- a/src/test/javascript/spec/component/code-editor/code-editor-monaco.component.spec.ts +++ b/src/test/javascript/spec/component/code-editor/code-editor-monaco.component.spec.ts @@ -127,7 +127,7 @@ describe('CodeEditorMonacoComponent', () => { ])('should correctly lock the editor on changes', (setup: () => void, shouldLock: boolean) => { comp.selectedFile = 'file'; comp.fileSession = { - [comp.selectedFile]: { code: 'some code', cursor: { row: 0, column: 0 }, loadingError: false }, + [comp.selectedFile]: { code: 'some code', cursor: { lineNumber: 0, column: 0 }, loadingError: false }, }; fixture.detectChanges(); setup(); @@ -138,7 +138,7 @@ describe('CodeEditorMonacoComponent', () => { it('should update the file session and notify when the file content changes', () => { const selectedFile = 'file'; const fileSession = { - [selectedFile]: { code: 'some unchanged code', cursor: { row: 0, column: 0 }, loadingError: false }, + [selectedFile]: { code: 'some unchanged code', cursor: { lineNumber: 0, column: 0 }, loadingError: false }, }; const newCode = 'some new code'; const valueCallbackStub = jest.fn(); @@ -161,7 +161,7 @@ describe('CodeEditorMonacoComponent', () => { const changeModelStub = jest.spyOn(comp.editor, 'changeModel').mockImplementation(); const presentFileName = 'present-file'; const presentFileSession = { - [presentFileName]: { code: 'code\ncode', cursor: { row: 1, column: 2 }, loadingError: false }, + [presentFileName]: { code: 'code\ncode', cursor: { lineNumber: 1, column: 2 }, loadingError: false }, }; fixture.detectChanges(); comp.fileSession = presentFileSession; @@ -172,7 +172,7 @@ describe('CodeEditorMonacoComponent', () => { expect(loadFileFromRepositoryStub).toHaveBeenCalledExactlyOnceWith(fileToLoad.fileName); expect(comp.fileSession).toEqual({ ...presentFileSession, - [fileToLoad.fileName]: { code: fileToLoad.fileContent, cursor: { column: 0, row: 0 }, loadingError: false }, + [fileToLoad.fileName]: { code: fileToLoad.fileContent, cursor: { column: 0, lineNumber: 0 }, loadingError: false }, }); expect(setPositionStub).toHaveBeenCalledTimes(2); expect(changeModelStub).toHaveBeenCalledTimes(2); @@ -181,7 +181,7 @@ describe('CodeEditorMonacoComponent', () => { it('should load a selected file after a loading error', async () => { const fileToLoad = { fileName: 'file-to-load', fileContent: 'some code' }; // File session after loading fails - const fileSession = { [fileToLoad.fileName]: { code: '', loadingError: true, cursor: { row: 0, column: 0 } } }; + const fileSession = { [fileToLoad.fileName]: { code: '', loadingError: true, cursor: { lineNumber: 0, column: 0 } } }; const loadedFileSubject = new BehaviorSubject(fileToLoad); loadFileFromRepositoryStub.mockReturnValue(loadedFileSubject); comp.fileSession = fileSession; @@ -189,14 +189,14 @@ describe('CodeEditorMonacoComponent', () => { fixture.detectChanges(); await comp.ngOnChanges({ selectedFile: new SimpleChange(undefined, fileToLoad, false) }); expect(loadFileFromRepositoryStub).toHaveBeenCalledOnce(); - expect(comp.fileSession).toEqual({ [fileToLoad.fileName]: { code: fileToLoad.fileContent, loadingError: false, cursor: { row: 0, column: 0 } } }); + expect(comp.fileSession).toEqual({ [fileToLoad.fileName]: { code: fileToLoad.fileContent, loadingError: false, cursor: { lineNumber: 0, column: 0 } } }); }); it('should not load binaries into the editor', async () => { const changeModelSpy = jest.spyOn(comp.editor, 'changeModel'); const fileName = 'file-to-load'; comp.fileSession = { - [fileName]: { code: '\0\0\0\0 (binary content)', loadingError: false, cursor: { row: 0, column: 0 } }, + [fileName]: { code: '\0\0\0\0 (binary content)', loadingError: false, cursor: { lineNumber: 0, column: 0 } }, }; fixture.detectChanges(); comp.selectedFile = fileName; @@ -221,7 +221,7 @@ describe('CodeEditorMonacoComponent', () => { await comp.ngOnChanges({ selectedFile: new SimpleChange(undefined, fileToLoad.fileName, false) }); expect(loadFileFromRepositoryStub).toHaveBeenCalledOnce(); expect(errorCallbackStub).toHaveBeenCalledExactlyOnceWith(errorCode); - expect(comp.fileSession).toEqual({ [fileToLoad.fileName]: { code: '', loadingError: true, cursor: { row: 0, column: 0 } } }); + expect(comp.fileSession).toEqual({ [fileToLoad.fileName]: { code: '', loadingError: true, cursor: { lineNumber: 0, column: 0 } } }); }); it('should discard local changes when the editor is refreshed', async () => { @@ -231,14 +231,14 @@ describe('CodeEditorMonacoComponent', () => { loadFileFromRepositoryStub.mockReturnValue(reloadedFileSubject); comp.selectedFile = fileToReload.fileName; comp.fileSession = { - [fileToReload.fileName]: { code: 'some local undiscarded changes', cursor: { row: 0, column: 0 }, loadingError: false }, + [fileToReload.fileName]: { code: 'some local undiscarded changes', cursor: { lineNumber: 0, column: 0 }, loadingError: false }, }; comp.editorState = EditorState.CLEAN; fixture.detectChanges(); // Simulate a refresh of the editor. await comp.ngOnChanges({ editorState: new SimpleChange(EditorState.REFRESHING, EditorState.CLEAN, false) }); expect(comp.fileSession).toEqual({ - [fileToReload.fileName]: { code: fileToReload.fileContent, cursor: { row: 0, column: 0 }, loadingError: false }, + [fileToReload.fileName]: { code: fileToReload.fileContent, cursor: { lineNumber: 0, column: 0 }, loadingError: false }, }); expect(editorResetStub).toHaveBeenCalledOnce(); }); @@ -246,7 +246,7 @@ describe('CodeEditorMonacoComponent', () => { it('should only load the currently selected file', async () => { const changeModelSpy = jest.spyOn(comp.editor, 'changeModel'); // Occurs when the first file load takes a while, but the user has already selected another file. - comp.fileSession = { ['file2']: { code: 'code2', cursor: { row: 0, column: 0 }, loadingError: false } }; + comp.fileSession = { ['file2']: { code: 'code2', cursor: { lineNumber: 0, column: 0 }, loadingError: false } }; fixture.detectChanges(); comp.selectedFile = 'file1'; const longLoadingFileSubject = new Subject(); @@ -266,7 +266,7 @@ describe('CodeEditorMonacoComponent', () => { fixture.detectChanges(); const selectedFile = 'file1'; const fileSession = { - [selectedFile]: { code: 'code\ncode', cursor: { row: 1, column: 2 }, loadingError: false }, + [selectedFile]: { code: 'code\ncode', cursor: { lineNumber: 1, column: 2 }, loadingError: false }, }; comp.fileSession = fileSession; comp.selectedFile = selectedFile; @@ -331,8 +331,8 @@ describe('CodeEditorMonacoComponent', () => { })); it('should add a new feedback widget', fakeAsync(() => { + // Feedback is stored as 0-based line numbers, but the editor requires 1-based line numbers. const feedbackLineOneBased = 3; - // Will be removed once Ace is gone. const feedbackLineZeroBased = feedbackLineOneBased - 1; const addLineWidgetStub = jest.spyOn(comp.editor, 'addLineWidget').mockImplementation(); const element = document.createElement('div'); @@ -427,8 +427,8 @@ describe('CodeEditorMonacoComponent', () => { const newFileName = 'new-file-name'; const otherFileName = 'other-file'; const fileSession = { - [oldFileName]: { code: 'renamed', cursor: { row: 0, column: 0 }, loadingError: false }, - [otherFileName]: { code: 'unrelated', cursor: { row: 0, column: 0 }, loadingError: false }, + [oldFileName]: { code: 'renamed', cursor: { lineNumber: 0, column: 0 }, loadingError: false }, + [otherFileName]: { code: 'unrelated', cursor: { lineNumber: 0, column: 0 }, loadingError: false }, }; fixture.detectChanges(); comp.fileSession = { ...fileSession }; @@ -444,8 +444,8 @@ describe('CodeEditorMonacoComponent', () => { const fileToDeleteName = 'file-to-delete'; const otherFileName = 'other-file'; const fileSession = { - [fileToDeleteName]: { code: 'will be deleted', cursor: { row: 0, column: 0 }, loadingError: false }, - [otherFileName]: { code: 'unrelated', cursor: { row: 0, column: 0 }, loadingError: false }, + [fileToDeleteName]: { code: 'will be deleted', cursor: { lineNumber: 0, column: 0 }, loadingError: false }, + [otherFileName]: { code: 'unrelated', cursor: { lineNumber: 0, column: 0 }, loadingError: false }, }; fixture.detectChanges(); comp.fileSession = { ...fileSession }; @@ -460,7 +460,7 @@ describe('CodeEditorMonacoComponent', () => { const fileToCreateName = 'file-to-create'; const otherFileName = 'other-file'; const fileSession = { - [otherFileName]: { code: 'unrelated', cursor: { row: 0, column: 0 }, loadingError: false }, + [otherFileName]: { code: 'unrelated', cursor: { lineNumber: 0, column: 0 }, loadingError: false }, }; fixture.detectChanges(); comp.fileSession = { ...fileSession }; @@ -468,7 +468,7 @@ describe('CodeEditorMonacoComponent', () => { await comp.onFileChange(createFileChange); expect(comp.fileSession).toEqual({ [otherFileName]: fileSession[otherFileName], - [fileToCreateName]: { code: '', cursor: { row: 0, column: 0 }, loadingError: false }, + [fileToCreateName]: { code: '', cursor: { lineNumber: 0, column: 0 }, loadingError: false }, }); }); diff --git a/src/test/javascript/spec/component/code-editor/code-editor-status.component.spec.ts b/src/test/javascript/spec/component/code-editor/code-editor-status.component.spec.ts index f3837316e767..d39be7e531e3 100644 --- a/src/test/javascript/spec/component/code-editor/code-editor-status.component.spec.ts +++ b/src/test/javascript/spec/component/code-editor/code-editor-status.component.spec.ts @@ -1,6 +1,5 @@ import { DebugElement } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { AceEditorModule } from 'app/shared/markdown-editor/ace-editor/ace-editor.module'; import { ArtemisTestModule } from '../../test.module'; import { By } from '@angular/platform-browser'; import { CodeEditorStatusComponent } from 'app/exercises/programming/shared/code-editor/status/code-editor-status.component'; @@ -15,7 +14,7 @@ describe('CodeEditorStatusComponent', () => { beforeEach(() => { TestBed.configureTestingModule({ - imports: [ArtemisTestModule, AceEditorModule, MockModule(NgbTooltipModule)], + imports: [ArtemisTestModule, MockModule(NgbTooltipModule)], declarations: [CodeEditorStatusComponent, TranslatePipeMock], }) .compileComponents() diff --git a/src/test/javascript/spec/component/competencies/competency.service.spec.ts b/src/test/javascript/spec/component/competencies/competency.service.spec.ts index 2b8a4c46813a..2d0bc277e85b 100644 --- a/src/test/javascript/spec/component/competencies/competency.service.spec.ts +++ b/src/test/javascript/spec/component/competencies/competency.service.spec.ts @@ -197,7 +197,7 @@ describe('CompetencyService', () => { const expected = defaultCompetencies; let response: any; - competencyService.generateCompetenciesFromCourseDescription(description, 1).subscribe((resp) => (response = resp)); + competencyService.generateCompetenciesFromCourseDescription(1, description, []).subscribe((resp) => (response = resp)); const req = httpTestingController.expectOne({ method: 'POST' }); req.flush(returnedFromService); tick(); diff --git a/src/test/javascript/spec/component/competencies/generate-competencies/course-description-form.component.spec.ts b/src/test/javascript/spec/component/competencies/generate-competencies/course-description-form.component.spec.ts index 20cfba29cac7..86f602263226 100644 --- a/src/test/javascript/spec/component/competencies/generate-competencies/course-description-form.component.spec.ts +++ b/src/test/javascript/spec/component/competencies/generate-competencies/course-description-form.component.spec.ts @@ -70,4 +70,14 @@ describe('CourseDescriptionFormComponent', () => { courseDescriptionComponent.courseDescriptionControl.setValue(descriptionTooLong); expect(courseDescriptionComponent.isSubmitPossible).toBeFalse(); }); + + it('should update the description', () => { + courseDescriptionComponentFixture.detectChanges(); + + expect(courseDescriptionComponent.courseDescriptionControl.value).toEqual(courseDescriptionComponent.placeholder); + + const description = 'I'.repeat(courseDescriptionComponent['DESCRIPTION_MIN'] + 1); + courseDescriptionComponent.setCourseDescription(description); + expect(courseDescriptionComponent.courseDescriptionControl.value).toEqual(description); + }); }); diff --git a/src/test/javascript/spec/component/competencies/generate-competencies/generate-competencies.component.spec.ts b/src/test/javascript/spec/component/competencies/generate-competencies/generate-competencies.component.spec.ts index f1dc252ada53..6888c6d994ea 100644 --- a/src/test/javascript/spec/component/competencies/generate-competencies/generate-competencies.component.spec.ts +++ b/src/test/javascript/spec/component/competencies/generate-competencies/generate-competencies.component.spec.ts @@ -1,4 +1,4 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; import { MockComponent, MockDirective, MockPipe, MockProvider } from 'ng-mocks'; import { ArtemisTestModule } from '../../../test.module'; @@ -12,7 +12,7 @@ import { CompetencyService } from 'app/course/competencies/competency.service'; import { AlertService } from 'app/core/util/alert.service'; import { MockNgbModalService } from '../../../helpers/mocks/service/mock-ngb-modal.service'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; -import { of } from 'rxjs'; +import { Subject, of } from 'rxjs'; import { MockRouter } from '../../../helpers/mocks/mock-router'; import { CourseDescriptionFormStubComponent } from './course-description-form-stub.component'; import { MockActivatedRoute } from '../../../helpers/mocks/activated-route/mock-activated-route'; @@ -21,17 +21,23 @@ import { HttpResponse } from '@angular/common/http'; import { By } from '@angular/platform-browser'; import { CompetencyRecommendationDetailComponent } from 'app/course/competencies/generate-competencies/competency-recommendation-detail.component'; import { DocumentationButtonComponent } from 'app/shared/components/documentation-button/documentation-button.component'; +import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { IrisStageStateDTO } from 'app/entities/iris/iris-stage-dto.model'; import { CourseDescriptionFormComponent } from 'app/course/competencies/generate-competencies/course-description-form.component'; import { CourseCompetencyService } from 'app/course/competencies/course-competency.service'; import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; import { ArtemisCompetenciesModule } from 'app/course/competencies/competency.module'; +import { CourseManagementService } from 'app/course/manage/course-management.service'; describe('GenerateCompetenciesComponent', () => { let generateCompetenciesComponentFixture: ComponentFixture; let generateCompetenciesComponent: GenerateCompetenciesComponent; + let mockWebSocketSubject: Subject; beforeEach(() => { + mockWebSocketSubject = new Subject(); + TestBed.configureTestingModule({ imports: [ArtemisTestModule, GenerateCompetenciesComponent, ArtemisSharedCommonModule, ArtemisSharedComponentModule, ArtemisCompetenciesModule], declarations: [ @@ -50,6 +56,16 @@ describe('GenerateCompetenciesComponent', () => { useValue: new MockActivatedRoute({ courseId: 1 }), }, { provide: Router, useClass: MockRouter }, + { + provide: JhiWebsocketService, + useValue: { + subscribe: jest.fn(), + receive: jest.fn(() => mockWebSocketSubject.asObservable()), + unsubscribe: jest.fn(), + }, + }, + MockProvider(CourseDescriptionFormComponent), + MockProvider(CourseManagementService), MockProvider(CourseCompetencyService), MockProvider(CompetencyService), MockProvider(AlertService), @@ -85,11 +101,32 @@ describe('GenerateCompetenciesComponent', () => { expect(getCompetencyRecommendationsSpy).toHaveBeenCalledOnce(); }); + it('should initialize the form with the course description', fakeAsync(() => { + generateCompetenciesComponentFixture.detectChanges(); + const courseDescription = 'Course Description'; + + const courseDescriptionComponent: CourseDescriptionFormComponent = generateCompetenciesComponentFixture.debugElement.query( + By.directive(CourseDescriptionFormComponent), + ).componentInstance; + const setCourseDescriptionSpy = jest.spyOn(courseDescriptionComponent, 'setCourseDescription'); + + // mock the course returned by CourseManagementService + const course = { description: courseDescription }; + const courseManagementService = TestBed.inject(CourseManagementService); + const getCourseSpy = jest.spyOn(courseManagementService, 'find').mockReturnValue(of(new HttpResponse({ body: course }))); + + generateCompetenciesComponent.ngOnInit(); + tick(); + + expect(getCourseSpy).toHaveBeenCalledOnce(); + expect(setCourseDescriptionSpy).toHaveBeenCalledWith(courseDescription); + })); + it('should add competency recommendations', () => { generateCompetenciesComponentFixture.detectChanges(); const courseDescription = 'Course Description'; const response = new HttpResponse({ - body: [{}, {}], + body: null, status: 200, }); const courseCompetencyService = TestBed.inject(CourseCompetencyService); @@ -100,10 +137,15 @@ describe('GenerateCompetenciesComponent', () => { expect(generateCompetenciesComponent.competencies.value).toHaveLength(0); generateCompetenciesComponent.getCompetencyRecommendations(courseDescription); + const websocketMessage = { + stages: [{ state: IrisStageStateDTO.DONE }], + result: [{ title: 'Title', description: 'Description', taxonomy: CompetencyTaxonomy.ANALYZE }], + }; + mockWebSocketSubject.next(websocketMessage); generateCompetenciesComponentFixture.detectChanges(); - expect(generateCompetenciesComponentFixture.debugElement.queryAll(By.directive(CompetencyRecommendationDetailComponent))).toHaveLength(2); - expect(generateCompetenciesComponent.competencies.value).toHaveLength(2); + expect(generateCompetenciesComponentFixture.debugElement.queryAll(By.directive(CompetencyRecommendationDetailComponent))).toHaveLength(1); + expect(generateCompetenciesComponent.competencies.value).toHaveLength(1); expect(getSpy).toHaveBeenCalledOnce(); }); @@ -190,17 +232,30 @@ describe('GenerateCompetenciesComponent', () => { it('should display alerts after generating', () => { const alertService = TestBed.inject(AlertService); + const response = new HttpResponse({ + body: null, + status: 200, + }); const courseCompetencyService = TestBed.inject(CourseCompetencyService); - const generateCompetenciesMock = jest.spyOn(courseCompetencyService, 'generateCompetenciesFromCourseDescription'); + const generateCompetenciesMock = jest.spyOn(courseCompetencyService, 'generateCompetenciesFromCourseDescription').mockReturnValue(of(response)); - generateCompetenciesMock.mockReturnValue(of({ body: [{ id: 1 }] } as HttpResponse)); const successMock = jest.spyOn(alertService, 'success'); - generateCompetenciesComponent.getCompetencyRecommendations(''); + generateCompetenciesComponent.getCompetencyRecommendations('Cool course description'); + const websocketMessage = { + stages: [{ state: IrisStageStateDTO.DONE }], + result: [{ title: 'Title', description: 'Description', taxonomy: CompetencyTaxonomy.ANALYZE }], + }; + mockWebSocketSubject.next(websocketMessage); expect(successMock).toHaveBeenCalledOnce(); + expect(generateCompetenciesMock).toHaveBeenCalledOnce(); - generateCompetenciesMock.mockReturnValue(of({} as HttpResponse)); const warnMock = jest.spyOn(alertService, 'warning'); - generateCompetenciesComponent.getCompetencyRecommendations(''); + generateCompetenciesComponent.getCompetencyRecommendations('Cool course description'); + const errorMessage = { + stages: [{ state: IrisStageStateDTO.ERROR }], + result: [{ title: 'Title', description: 'Description', taxonomy: CompetencyTaxonomy.ANALYZE }], + }; + mockWebSocketSubject.next(errorMessage); expect(warnMock).toHaveBeenCalled(); }); diff --git a/src/test/javascript/spec/component/competencies/prerequisite.service.spec.ts b/src/test/javascript/spec/component/competencies/prerequisite.service.spec.ts index 23ceabf755ac..3911a601d7df 100644 --- a/src/test/javascript/spec/component/competencies/prerequisite.service.spec.ts +++ b/src/test/javascript/spec/component/competencies/prerequisite.service.spec.ts @@ -198,7 +198,7 @@ describe('PrerequisiteService', () => { const expected = defaultPrerequisites; let response: any; - prerequisiteService.generateCompetenciesFromCourseDescription(description, 1).subscribe((resp) => (response = resp)); + prerequisiteService.generateCompetenciesFromCourseDescription(1, description, []).subscribe((resp) => (response = resp)); const req = httpTestingController.expectOne({ method: 'POST' }); req.flush(returnedFromService); tick(); diff --git a/src/test/javascript/spec/component/course/course-exercises.component.spec.ts b/src/test/javascript/spec/component/course/course-exercises.component.spec.ts index 017814b34c5e..3a2e57aa1588 100644 --- a/src/test/javascript/spec/component/course/course-exercises.component.spec.ts +++ b/src/test/javascript/spec/component/course/course-exercises.component.spec.ts @@ -29,6 +29,7 @@ import { SidebarComponent } from 'app/shared/sidebar/sidebar.component'; import { SearchFilterPipe } from 'app/shared/pipes/search-filter.pipe'; import { SearchFilterComponent } from 'app/shared/search-filter/search-filter.component'; import { By } from '@angular/platform-browser'; +import { TranslateDirective } from 'app/shared/language/translate.directive'; describe('CourseExercisesComponent', () => { let fixture: ComponentFixture; @@ -44,7 +45,7 @@ describe('CourseExercisesComponent', () => { beforeEach(() => { TestBed.configureTestingModule({ - imports: [ArtemisTestModule, FormsModule, RouterTestingModule.withRoutes([]), MockModule(ReactiveFormsModule)], + imports: [ArtemisTestModule, FormsModule, RouterTestingModule.withRoutes([]), MockModule(ReactiveFormsModule), MockDirective(TranslateDirective)], declarations: [ CourseExercisesComponent, SidebarComponent, diff --git a/src/test/javascript/spec/component/course/exercise-filter.model.spec.ts b/src/test/javascript/spec/component/course/exercise-filter.model.spec.ts index 8aadaa3d2189..232aab57fafa 100644 --- a/src/test/javascript/spec/component/course/exercise-filter.model.spec.ts +++ b/src/test/javascript/spec/component/course/exercise-filter.model.spec.ts @@ -6,10 +6,8 @@ import { ProgrammingExercise } from 'app/entities/programming-exercise.model'; import { Exercise } from 'app/entities/exercise.model'; describe('Exercise Filter Test', () => { - const category1 = new ExerciseCategory(); - category1.category = 'Easy'; - const category2 = new ExerciseCategory(); - category2.category = 'Hard'; + const category1 = new ExerciseCategory('Easy', undefined); + const category2 = new ExerciseCategory('Hard', undefined); const course: Course = { id: 123 } as Course; const exercise1 = new ProgrammingExercise(course, undefined); exercise1.id = 1; diff --git a/src/test/javascript/spec/component/drag-and-drop-question/drag-and-drop-question.component.spec.ts b/src/test/javascript/spec/component/drag-and-drop-question/drag-and-drop-question.component.spec.ts index 9d8afe5529f9..4b11c8785650 100644 --- a/src/test/javascript/spec/component/drag-and-drop-question/drag-and-drop-question.component.spec.ts +++ b/src/test/javascript/spec/component/drag-and-drop-question/drag-and-drop-question.component.spec.ts @@ -8,7 +8,6 @@ import { DragAndDropQuestionComponent } from 'app/exercises/quiz/shared/question import { DragItemComponent } from 'app/exercises/quiz/shared/questions/drag-and-drop-question/drag-item.component'; import { QuizScoringInfoStudentModalComponent } from 'app/exercises/quiz/shared/questions/quiz-scoring-infostudent-modal/quiz-scoring-info-student-modal.component'; import { SecuredImageComponent } from 'app/shared/image/secured-image.component'; -import { MarkdownEditorComponent } from 'app/shared/markdown-editor/markdown-editor.component'; import { ArtemisMarkdownService } from 'app/shared/markdown.service'; import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; import { MockComponent, MockPipe, MockProvider } from 'ng-mocks'; @@ -34,7 +33,6 @@ describe('DragAndDropQuestionComponent', () => { declarations: [ DragAndDropQuestionComponent, MockPipe(ArtemisTranslatePipe), - MockComponent(MarkdownEditorComponent), MockComponent(SecuredImageComponent), MockComponent(DragAndDropQuestionComponent), MockComponent(QuizScoringInfoStudentModalComponent), diff --git a/src/test/javascript/spec/component/exercises/shared/headers/header-exercise-page-with-details.component.spec.ts b/src/test/javascript/spec/component/exercises/shared/headers/header-exercise-page-with-details.component.spec.ts index 6d38f089db69..6682b12aa12c 100644 --- a/src/test/javascript/spec/component/exercises/shared/headers/header-exercise-page-with-details.component.spec.ts +++ b/src/test/javascript/spec/component/exercises/shared/headers/header-exercise-page-with-details.component.spec.ts @@ -67,8 +67,7 @@ describe('HeaderExercisePageWithDetails', () => { expect(component.icon.iconName).toBe('keyboard'); // dueDate, categories, examMode should also be set if the necessary information is known - const category = new ExerciseCategory(); - category.category = 'testcategory'; + const category = new ExerciseCategory('testcategory', undefined); const categories = [category]; exercise.categories = categories; exam.endDate = dayjs().subtract(1, 'day'); diff --git a/src/test/javascript/spec/component/file-upload-exercise/file-upload-exercise-update.component.spec.ts b/src/test/javascript/spec/component/file-upload-exercise/file-upload-exercise-update.component.spec.ts index d104f657bdf3..caec2f8dbd54 100644 --- a/src/test/javascript/spec/component/file-upload-exercise/file-upload-exercise-update.component.spec.ts +++ b/src/test/javascript/spec/component/file-upload-exercise/file-upload-exercise-update.component.spec.ts @@ -23,6 +23,7 @@ import { fileUploadExercise } from '../../helpers/mocks/service/mock-file-upload import { ExerciseTitleChannelNameComponent } from 'app/exercises/shared/exercise-title-channel-name/exercise-title-channel-name.component'; import { TeamConfigFormGroupComponent } from 'app/exercises/shared/team-config-form-group/team-config-form-group.component'; import { NgModel } from '@angular/forms'; +import { ExerciseCategory } from 'app/entities/exercise-category.model'; describe('FileUploadExerciseUpdateComponent', () => { let comp: FileUploadExerciseUpdateComponent; @@ -321,7 +322,7 @@ describe('FileUploadExerciseUpdateComponent', () => { it('should updateCategories properly by making category available for selection again when removing it', () => { comp.fileUploadExercise = fileUploadExercise; comp.exerciseCategories = []; - const newCategories = [{ category: 'Easy' }, { category: 'Hard' }]; + const newCategories = [new ExerciseCategory('Easy', undefined), new ExerciseCategory('Hard', undefined)]; comp.updateCategories(newCategories); diff --git a/src/test/javascript/spec/component/learning-paths/components/learning-path-lecture-unit.component.spec.ts b/src/test/javascript/spec/component/learning-paths/components/learning-path-lecture-unit.component.spec.ts index e82d4195a3f6..a77d15c0709f 100644 --- a/src/test/javascript/spec/component/learning-paths/components/learning-path-lecture-unit.component.spec.ts +++ b/src/test/javascript/spec/component/learning-paths/components/learning-path-lecture-unit.component.spec.ts @@ -86,7 +86,7 @@ describe('LearningPathLectureUnitComponent', () => { }); it('should get lecture unit', async () => { - const getLectureUnitSpy = jest.spyOn(component, 'getLectureUnit'); + const getLectureUnitSpy = jest.spyOn(component, 'loadLectureUnit'); fixture.detectChanges(); await fixture.whenStable(); @@ -102,7 +102,7 @@ describe('LearningPathLectureUnitComponent', () => { }); it('should set loading state correctly', async () => { - const setIsLoadingSpy = jest.spyOn(component.isLectureUnitLoading, 'set'); + const setIsLoadingSpy = jest.spyOn(component.isLoading, 'set'); fixture.detectChanges(); await fixture.whenStable(); fixture.detectChanges(); diff --git a/src/test/javascript/spec/component/markdown-editor/attachment-command.spec.ts b/src/test/javascript/spec/component/markdown-editor/attachment-command.spec.ts deleted file mode 100644 index 8e32773497dd..000000000000 --- a/src/test/javascript/spec/component/markdown-editor/attachment-command.spec.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { AceEditorModule } from 'app/shared/markdown-editor/ace-editor/ace-editor.module'; -import { MarkdownEditorComponent } from 'app/shared/markdown-editor/markdown-editor.component'; -import { ArtemisMarkdownEditorModule } from 'app/shared/markdown-editor/markdown-editor.module'; -import { ArtemisTestModule } from '../../test.module'; -import { AttachmentCommand } from 'app/shared/markdown-editor/commands/attachmentCommand'; -import { Command } from 'app/shared/markdown-editor/commands/command'; - -describe('AttachmentCommand', () => { - let comp: MarkdownEditorComponent; - let fixture: ComponentFixture; - let command: Command; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ArtemisTestModule, AceEditorModule, ArtemisMarkdownEditorModule], - }) - .compileComponents() - .then(() => { - fixture = TestBed.createComponent(MarkdownEditorComponent); - comp = fixture.componentInstance; - - command = new AttachmentCommand(); - comp.defaultCommands = [command]; - fixture.detectChanges(); - comp.ngAfterViewInit(); - }); - }); - - it('should add ![](http://) on execute', () => { - comp.aceEditorContainer.getEditor().setValue(''); - command.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('![](http://)'); - }); - - it('should remove ![](http://) on execute', () => { - comp.aceEditorContainer.getEditor().setValue('![](http://)test'); - command.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('test'); - }); -}); diff --git a/src/test/javascript/spec/component/markdown-editor/bold-command.spec.ts b/src/test/javascript/spec/component/markdown-editor/bold-command.spec.ts deleted file mode 100644 index c80e43eb0eed..000000000000 --- a/src/test/javascript/spec/component/markdown-editor/bold-command.spec.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { AceEditorModule } from 'app/shared/markdown-editor/ace-editor/ace-editor.module'; -import { BoldCommand } from 'app/shared/markdown-editor/commands/bold.command'; -import { MarkdownEditorComponent } from 'app/shared/markdown-editor/markdown-editor.component'; -import { ArtemisMarkdownEditorModule } from 'app/shared/markdown-editor/markdown-editor.module'; -import { ArtemisTestModule } from '../../test.module'; -import { Command } from 'app/shared/markdown-editor/commands/command'; - -describe('BoldCommand', () => { - let comp: MarkdownEditorComponent; - let fixture: ComponentFixture; - let command: Command; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ArtemisTestModule, AceEditorModule, ArtemisMarkdownEditorModule], - }) - .compileComponents() - .then(() => { - fixture = TestBed.createComponent(MarkdownEditorComponent); - comp = fixture.componentInstance; - - command = new BoldCommand(); - comp.defaultCommands = [command]; - fixture.detectChanges(); - comp.ngAfterViewInit(); - }); - }); - - it('should add **** on execute', () => { - comp.aceEditorContainer.getEditor().setValue('bold'); - command.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('**bold**'); - }); - - it('should remove **** on execute', () => { - comp.aceEditorContainer.getEditor().setValue('**bold**'); - command.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('bold'); - }); -}); diff --git a/src/test/javascript/spec/component/markdown-editor/channelMentionCommand.spec.ts b/src/test/javascript/spec/component/markdown-editor/channelMentionCommand.spec.ts deleted file mode 100644 index 1f461eab4b56..000000000000 --- a/src/test/javascript/spec/component/markdown-editor/channelMentionCommand.spec.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { MetisService } from 'app/shared/metis/metis.service'; -import { HttpResponse } from '@angular/common/http'; -import { of } from 'rxjs'; -import { SelectableItem } from 'app/shared/markdown-editor/commands/interactiveSearchCommand'; -import { ChannelMentionCommand } from 'app/shared/markdown-editor/commands/courseArtifactReferenceCommands/channelMentionCommand'; -import { ChannelService } from 'app/shared/metis/conversations/channel.service'; -import { ChannelIdAndNameDTO } from 'app/entities/metis/conversation/channel.model'; -import { CourseInformationSharingConfiguration } from 'app/entities/course.model'; - -describe('ChannelMentionCommand', () => { - let channelMentionCommand: ChannelMentionCommand; - let channelServiceMock: Partial; - let metisServiceMock: Partial; - let aceEditorMock: any; - let selectWithSearchComponent: any; - - beforeEach(() => { - selectWithSearchComponent = { - open: () => {}, - updateSearchTerm: () => {}, - close: () => {}, - }; - - channelServiceMock = { - getPublicChannelsOfCourse: () => - of( - new HttpResponse({ - body: [ - { name: 'Channel 1', id: 1 }, - { name: 'Channel 2', id: 2 }, - ], - }), - ), - }; - - metisServiceMock = { - getCourse: () => ({ id: 1, courseInformationSharingConfiguration: CourseInformationSharingConfiguration.COMMUNICATION_AND_MESSAGING }), - }; - - aceEditorMock = { - command: undefined, - commands: { - addCommand: (obj: any) => { - aceEditorMock.command = obj; - }, - }, - execCommand: () => {}, - getCursorPosition: () => ({ row: 0, column: 0 }), - focus: () => {}, - session: { - getDocument: () => ({ - removeInLine: () => {}, - }), - getLine: () => '', - }, - insert: () => {}, - }; - - // Create an instance of ChannelMentionCommand with mock services - channelMentionCommand = new ChannelMentionCommand(channelServiceMock as ChannelService, metisServiceMock as MetisService); - channelMentionCommand.setSelectWithSearchComponent(selectWithSearchComponent); - }); - - it('should create an instance of ChannelMentionCommand', () => { - expect(channelMentionCommand).toBeTruthy(); - }); - - it('should perform a channel search and cache result', () => { - const getChannelsOfCourseSpy = jest.spyOn(channelServiceMock, 'getPublicChannelsOfCourse'); - - channelMentionCommand.performSearch('channel').subscribe((response) => { - expect(response.body).toEqual([ - { name: 'Channel 1', id: 1 }, - { name: 'Channel 2', id: 2 }, - ]); - }); - - channelMentionCommand.performSearch('channel').subscribe(); - - expect(getChannelsOfCourseSpy).toHaveBeenCalledExactlyOnceWith(1); - }); - - it('should filter channels based on searchTerm', () => { - const getChannelsOfCourseSpy = jest.spyOn(channelServiceMock, 'getPublicChannelsOfCourse'); - - channelMentionCommand.performSearch('1').subscribe((response) => { - expect(response.body).toEqual([{ name: 'Channel 1', id: 1 }]); - }); - - channelMentionCommand.performSearch('2').subscribe((response) => { - expect(response.body).toEqual([{ name: 'Channel 2', id: 2 }]); - }); - - expect(getChannelsOfCourseSpy).toHaveBeenCalledExactlyOnceWith(1); - }); - - it('should insert selection', () => { - channelMentionCommand.setEditor(aceEditorMock); - - const focusSpy = jest.spyOn(aceEditorMock, 'focus'); - - // Simulate open selection menu via triggering command - aceEditorMock.command.exec(aceEditorMock); - - channelMentionCommand.insertSelection({ name: 'Channel 1', id: 1 } as SelectableItem); - - // The editor is focused twice: Once for the execution of the command, once after the text insertion - expect(focusSpy).toHaveBeenCalledTimes(2); - }); - - it('should execute the command', () => { - channelMentionCommand.setEditor(aceEditorMock); - - const execCommandSpy = jest.spyOn(aceEditorMock, 'execCommand'); - - channelMentionCommand.execute(); - - expect(execCommandSpy).toHaveBeenCalledExactlyOnceWith('#'); - }); -}); diff --git a/src/test/javascript/spec/component/markdown-editor/code-command.spec.ts b/src/test/javascript/spec/component/markdown-editor/code-command.spec.ts deleted file mode 100644 index eeca9ece8100..000000000000 --- a/src/test/javascript/spec/component/markdown-editor/code-command.spec.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { AceEditorModule } from 'app/shared/markdown-editor/ace-editor/ace-editor.module'; -import { CodeCommand } from 'app/shared/markdown-editor/commands/code.command'; -import { MarkdownEditorComponent } from 'app/shared/markdown-editor/markdown-editor.component'; -import { ArtemisMarkdownEditorModule } from 'app/shared/markdown-editor/markdown-editor.module'; -import { ArtemisTestModule } from '../../test.module'; -import { Command } from 'app/shared/markdown-editor/commands/command'; - -describe('CodeCommand', () => { - let comp: MarkdownEditorComponent; - let fixture: ComponentFixture; - let command: Command; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ArtemisTestModule, AceEditorModule, ArtemisMarkdownEditorModule], - }) - .compileComponents() - .then(() => { - fixture = TestBed.createComponent(MarkdownEditorComponent); - comp = fixture.componentInstance; - - command = new CodeCommand(); - comp.defaultCommands = [command]; - fixture.detectChanges(); - comp.ngAfterViewInit(); - }); - }); - - it('should add `` on execute', () => { - comp.aceEditorContainer.getEditor().setValue('code'); - command.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('`code`'); - }); - - it('should remove `` on execute', () => { - comp.aceEditorContainer.getEditor().setValue('`code`'); - command.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('code'); - }); -}); diff --git a/src/test/javascript/spec/component/markdown-editor/codeblock-command.spec.ts b/src/test/javascript/spec/component/markdown-editor/codeblock-command.spec.ts deleted file mode 100644 index 60cd66efa68c..000000000000 --- a/src/test/javascript/spec/component/markdown-editor/codeblock-command.spec.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { AceEditorModule } from 'app/shared/markdown-editor/ace-editor/ace-editor.module'; -import { MarkdownEditorComponent } from 'app/shared/markdown-editor/markdown-editor.component'; -import { ArtemisMarkdownEditorModule } from 'app/shared/markdown-editor/markdown-editor.module'; -import { ArtemisTestModule } from '../../test.module'; -import { CodeBlockCommand } from 'app/shared/markdown-editor/commands/codeblock.command'; - -describe('CodeBlockCommand', () => { - let comp: MarkdownEditorComponent; - let fixture: ComponentFixture; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ArtemisTestModule, AceEditorModule, ArtemisMarkdownEditorModule], - }) - .compileComponents() - .then(() => { - fixture = TestBed.createComponent(MarkdownEditorComponent); - comp = fixture.componentInstance; - }); - }); - - it('should add ```java\n``` on execute', () => { - const command = new CodeBlockCommand(); - comp.defaultCommands = [command]; - fixture.detectChanges(); - comp.ngAfterViewInit(); - comp.aceEditorContainer.getEditor().setValue('code'); - - command.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('```java\ncode\n```'); - }); -}); diff --git a/src/test/javascript/spec/component/markdown-editor/color-picker-command.spec.ts b/src/test/javascript/spec/component/markdown-editor/color-picker-command.spec.ts deleted file mode 100644 index ffcf3b04ce5e..000000000000 --- a/src/test/javascript/spec/component/markdown-editor/color-picker-command.spec.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { AceEditorModule } from 'app/shared/markdown-editor/ace-editor/ace-editor.module'; -import { MockComponent } from 'ng-mocks'; -import { FaLayersComponent } from '@fortawesome/angular-fontawesome'; -import { MarkdownEditorComponent } from 'app/shared/markdown-editor/markdown-editor.component'; -import { ArtemisMarkdownEditorModule } from 'app/shared/markdown-editor/markdown-editor.module'; -import { ArtemisTestModule } from '../../test.module'; -import { ColorPickerCommand } from 'app/shared/markdown-editor/commands/colorPicker.command'; -import { Command } from 'app/shared/markdown-editor/commands/command'; - -describe('ColorPickerCommand', () => { - let comp: MarkdownEditorComponent; - let fixture: ComponentFixture; - let command: Command; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ArtemisTestModule, AceEditorModule, ArtemisMarkdownEditorModule], - declarations: [MockComponent(FaLayersComponent)], - }) - .compileComponents() - .then(() => { - fixture = TestBed.createComponent(MarkdownEditorComponent); - comp = fixture.componentInstance; - - command = new ColorPickerCommand(); - comp.colorCommands = [command]; - fixture.detectChanges(); - comp.ngAfterViewInit(); - }); - }); - - const colorTable = [ - ['#ca2024', 'red'], - ['#3ea119', 'green'], - ['#ffffff', 'white'], - ['#fffa5c', 'yellow'], - ['#0d3cc2', 'blue'], - ['#b05db8', 'lila'], - ['#d86b1f', 'orange'], - ]; - - it.each(colorTable)('should add color %s on execute', (hex, color) => { - comp.aceEditorContainer.getEditor().setValue('test'); - command.execute(hex); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('test'); - }); - - it('should add color black on execute', () => { - comp.aceEditorContainer.getEditor().setValue('test'); - command.execute('#000000'); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('test'); - }); - - it.each(colorTable)('should remove color %s on execute', (hex, color) => { - comp.aceEditorContainer.getEditor().setValue('test'); - command.execute('#ffffff'); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('test'); - }); -}); diff --git a/src/test/javascript/spec/component/markdown-editor/fullscreen-command.spec.ts b/src/test/javascript/spec/component/markdown-editor/fullscreen-command.spec.ts deleted file mode 100644 index a11404b80e20..000000000000 --- a/src/test/javascript/spec/component/markdown-editor/fullscreen-command.spec.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { AceEditorModule } from 'app/shared/markdown-editor/ace-editor/ace-editor.module'; -import { FullscreenCommand } from 'app/shared/markdown-editor/commands/fullscreen.command'; -import { MarkdownEditorComponent } from 'app/shared/markdown-editor/markdown-editor.component'; -import { ArtemisMarkdownEditorModule } from 'app/shared/markdown-editor/markdown-editor.module'; -import { ArtemisTestModule } from '../../test.module'; -import * as FullscreenUtil from 'app/shared/util/fullscreen.util'; - -describe('FullscreenCommand', () => { - let comp: MarkdownEditorComponent; - let fixture: ComponentFixture; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ArtemisTestModule, AceEditorModule, ArtemisMarkdownEditorModule], - }) - .compileComponents() - .then(() => { - fixture = TestBed.createComponent(MarkdownEditorComponent); - comp = fixture.componentInstance; - }); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('should enable fullscreen on execute', () => { - jest.spyOn(FullscreenUtil, 'enterFullscreen'); - jest.spyOn(FullscreenUtil, 'isFullScreen').mockReturnValue(false); - - const command = new FullscreenCommand(); - comp.defaultCommands = [command]; - fixture.detectChanges(); - comp.ngAfterViewInit(); - - command.execute(); - expect(FullscreenUtil.isFullScreen).toHaveBeenCalledOnce(); - expect(FullscreenUtil.enterFullscreen).toHaveBeenCalledOnce(); - }); - - it('should disable fullscreen on execute', () => { - jest.spyOn(FullscreenUtil, 'exitFullscreen'); - jest.spyOn(FullscreenUtil, 'isFullScreen').mockReturnValue(true); - - const command = new FullscreenCommand(); - comp.defaultCommands = [command]; - fixture.detectChanges(); - comp.ngAfterViewInit(); - - command.execute(); - expect(FullscreenUtil.isFullScreen).toHaveBeenCalledOnce(); - expect(FullscreenUtil.exitFullscreen).toHaveBeenCalledOnce(); - }); -}); diff --git a/src/test/javascript/spec/component/markdown-editor/gradingInstruction-command.component.spec.ts b/src/test/javascript/spec/component/markdown-editor/gradingInstruction-command.component.spec.ts deleted file mode 100644 index 5b99e057c7bf..000000000000 --- a/src/test/javascript/spec/component/markdown-editor/gradingInstruction-command.component.spec.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { AceEditorModule } from 'app/shared/markdown-editor/ace-editor/ace-editor.module'; -import { MarkdownEditorComponent } from 'app/shared/markdown-editor/markdown-editor.component'; -import { ArtemisMarkdownEditorModule } from 'app/shared/markdown-editor/markdown-editor.module'; -import { ArtemisTestModule } from '../../test.module'; -import { GradingInstructionCommand } from 'app/shared/markdown-editor/domainCommands/gradingInstruction.command'; -import { CreditsCommand } from 'app/shared/markdown-editor/domainCommands/credits.command'; -import { GradingScaleCommand } from 'app/shared/markdown-editor/domainCommands/gradingScaleCommand'; -import { InstructionDescriptionCommand } from 'app/shared/markdown-editor/domainCommands/instructionDescription.command'; -import { FeedbackCommand } from 'app/shared/markdown-editor/domainCommands/feedback.command'; -import { UsageCountCommand } from 'app/shared/markdown-editor/domainCommands/usageCount.command'; -import { GradingCriterionCommand } from 'app/shared/markdown-editor/domainCommands/gradingCriterionCommand'; -import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; -import { MockDirective, MockPipe } from 'ng-mocks'; -import { NgModel } from '@angular/forms'; - -describe('Grading Instruction Command', () => { - let comp: MarkdownEditorComponent; - let fixture: ComponentFixture; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ArtemisTestModule, AceEditorModule, ArtemisMarkdownEditorModule], - declarations: [MockPipe(ArtemisTranslatePipe), MockDirective(NgModel)], - }) - .compileComponents() - .then(() => { - fixture = TestBed.createComponent(MarkdownEditorComponent); - comp = fixture.componentInstance; - }); - }); - - it('should add instruction identifiers and parameters on execute', () => { - const gradingInstructionCommand = new GradingInstructionCommand(); - comp.domainCommands = [gradingInstructionCommand]; - fixture.detectChanges(); - comp.ngAfterViewInit(); - - gradingInstructionCommand.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe( - '[instruction]' + - '\n' + - '\t' + - ('[credits]' + - CreditsCommand.TEXT + - '\n' + - '\t' + - '[gradingScale]' + - GradingScaleCommand.TEXT + - '\n' + - '\t' + - '[description]' + - InstructionDescriptionCommand.TEXT + - '\n' + - '\t' + - '[feedback]' + - FeedbackCommand.TEXT + - '\n' + - '\t' + - '[maxCountInScore]' + - UsageCountCommand.TEXT) + - '\n', - ); - }); - - it('should add CriteriaCommand identifier on execute', () => { - const criterionCommand = new GradingCriterionCommand(); - const gradingInstructionCommand = new GradingInstructionCommand(); - comp.domainCommands = [criterionCommand, gradingInstructionCommand]; - fixture.detectChanges(); - comp.ngAfterViewInit(); - - criterionCommand.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('\n' + '[criterion]' + GradingCriterionCommand.TEXT + '\n' + gradingInstructionCommand.instructionText()); - }); -}); diff --git a/src/test/javascript/spec/component/markdown-editor/headingCommands.spec.ts b/src/test/javascript/spec/component/markdown-editor/headingCommands.spec.ts deleted file mode 100644 index 1e842133b7fe..000000000000 --- a/src/test/javascript/spec/component/markdown-editor/headingCommands.spec.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { AceEditorModule } from 'app/shared/markdown-editor/ace-editor/ace-editor.module'; -import { MarkdownEditorComponent } from 'app/shared/markdown-editor/markdown-editor.component'; -import { ArtemisMarkdownEditorModule } from 'app/shared/markdown-editor/markdown-editor.module'; -import { ArtemisTestModule } from '../../test.module'; -import { HeadingOneCommand } from 'app/shared/markdown-editor/commands/headingOne.command'; -import { HeadingTwoCommand } from 'app/shared/markdown-editor/commands/headingTwo.command'; -import { HeadingThreeCommand } from 'app/shared/markdown-editor/commands/headingThree.command'; - -describe('HeadingOneCommand', () => { - let comp: MarkdownEditorComponent; - let fixture: ComponentFixture; - let headingOneCommand = new HeadingOneCommand(); - let headingTwoCommand = new HeadingTwoCommand(); - let headingThreeCommand = new HeadingThreeCommand(); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ArtemisTestModule, AceEditorModule, ArtemisMarkdownEditorModule], - }) - .compileComponents() - .then(() => { - fixture = TestBed.createComponent(MarkdownEditorComponent); - comp = fixture.componentInstance; - headingOneCommand = new HeadingOneCommand(); - headingTwoCommand = new HeadingTwoCommand(); - headingThreeCommand = new HeadingThreeCommand(); - comp.defaultCommands = [headingOneCommand, headingTwoCommand, headingThreeCommand]; - fixture.detectChanges(); - comp.ngAfterViewInit(); - }); - }); - - it('should add # Heading 1,2,3 on execute when no text is selected', () => { - headingOneCommand.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('# Heading 1'); - comp.aceEditorContainer.getEditor().setValue(''); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe(''); - headingTwoCommand.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('## Heading 2'); - comp.aceEditorContainer.getEditor().setValue(''); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe(''); - headingThreeCommand.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('### Heading 3'); - }); - - it('should add #, ##, ### on execute when text is selected', () => { - jest.spyOn(comp.aceEditorContainer.getEditor(), 'getSelectedText').mockReturnValue('lorem'); - - headingOneCommand.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('# lorem'); - comp.aceEditorContainer.getEditor().setValue('lorem'); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('lorem'); - headingTwoCommand.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('## lorem'); - comp.aceEditorContainer.getEditor().setValue('lorem'); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('lorem'); - headingThreeCommand.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('### lorem'); - }); - - it('should remove #, ##, ### on execute when text of header selected', () => { - comp.aceEditorContainer.getEditor().setValue('# lorem'); - headingOneCommand.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('lorem'); - comp.aceEditorContainer.getEditor().setValue('## lorem'); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('## lorem'); - headingTwoCommand.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('lorem'); - comp.aceEditorContainer.getEditor().setValue('### lorem'); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('### lorem'); - headingThreeCommand.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('lorem'); - }); - - it('should remove #, ##, ### with Heading text on execute when text of header selected', () => { - comp.aceEditorContainer.getEditor().setValue('# Heading 1'); - headingOneCommand.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe(''); - comp.aceEditorContainer.getEditor().setValue('## Heading 2'); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('## Heading 2'); - headingTwoCommand.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe(''); - comp.aceEditorContainer.getEditor().setValue('### Heading 3'); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('### Heading 3'); - headingThreeCommand.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe(''); - }); -}); diff --git a/src/test/javascript/spec/component/markdown-editor/italic-command.spec.ts b/src/test/javascript/spec/component/markdown-editor/italic-command.spec.ts deleted file mode 100644 index a39dfd15ef90..000000000000 --- a/src/test/javascript/spec/component/markdown-editor/italic-command.spec.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { AceEditorModule } from 'app/shared/markdown-editor/ace-editor/ace-editor.module'; -import { ItalicCommand } from 'app/shared/markdown-editor/commands/italic.command'; -import { MarkdownEditorComponent } from 'app/shared/markdown-editor/markdown-editor.component'; -import { ArtemisMarkdownEditorModule } from 'app/shared/markdown-editor/markdown-editor.module'; -import { ArtemisTestModule } from '../../test.module'; -import { Command } from 'app/shared/markdown-editor/commands/command'; - -describe('ItalicCommand', () => { - let comp: MarkdownEditorComponent; - let fixture: ComponentFixture; - let command: Command; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ArtemisTestModule, AceEditorModule, ArtemisMarkdownEditorModule], - }) - .compileComponents() - .then(() => { - fixture = TestBed.createComponent(MarkdownEditorComponent); - comp = fixture.componentInstance; - - command = new ItalicCommand(); - comp.defaultCommands = [command]; - fixture.detectChanges(); - comp.ngAfterViewInit(); - }); - }); - - it('should add ** on execute', () => { - comp.aceEditorContainer.getEditor().setValue('italic'); - command.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('*italic*'); - }); - - it('should remove ** on execute', () => { - comp.aceEditorContainer.getEditor().setValue('*italic*'); - command.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('italic'); - }); -}); diff --git a/src/test/javascript/spec/component/markdown-editor/katex-command.spec.ts b/src/test/javascript/spec/component/markdown-editor/katex-command.spec.ts deleted file mode 100644 index 16d34e2a462e..000000000000 --- a/src/test/javascript/spec/component/markdown-editor/katex-command.spec.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { AceEditorModule } from 'app/shared/markdown-editor/ace-editor/ace-editor.module'; -import { MarkdownEditorComponent } from 'app/shared/markdown-editor/markdown-editor.component'; -import { ArtemisMarkdownEditorModule } from 'app/shared/markdown-editor/markdown-editor.module'; -import { KatexCommand } from 'app/shared/markdown-editor/commands/katex.command'; -import { ArtemisTestModule } from '../../test.module'; - -describe('KatexCommand', () => { - let comp: MarkdownEditorComponent; - let fixture: ComponentFixture; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ArtemisTestModule, AceEditorModule, ArtemisMarkdownEditorModule], - }) - .compileComponents() - .then(() => { - fixture = TestBed.createComponent(MarkdownEditorComponent); - comp = fixture.componentInstance; - }); - }); - - it('should add insert the sample e-function into the editor on execute', () => { - const katexCommand = new KatexCommand(); - comp.domainCommands = [katexCommand]; - fixture.detectChanges(); - comp.ngAfterViewInit(); - - katexCommand.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('$$ e^{\\frac{1}{4} y^2} $$'); - }); -}); diff --git a/src/test/javascript/spec/component/markdown-editor/link-command.spec.ts b/src/test/javascript/spec/component/markdown-editor/link-command.spec.ts deleted file mode 100644 index 442cfe7212d8..000000000000 --- a/src/test/javascript/spec/component/markdown-editor/link-command.spec.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { AceEditorModule } from 'app/shared/markdown-editor/ace-editor/ace-editor.module'; -import { MarkdownEditorComponent } from 'app/shared/markdown-editor/markdown-editor.component'; -import { ArtemisMarkdownEditorModule } from 'app/shared/markdown-editor/markdown-editor.module'; -import { ArtemisTestModule } from '../../test.module'; -import { LinkCommand } from 'app/shared/markdown-editor/commands/link.command'; -import { Command } from 'app/shared/markdown-editor/commands/command'; - -describe('LinkCommand', () => { - let comp: MarkdownEditorComponent; - let fixture: ComponentFixture; - let command: Command; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ArtemisTestModule, AceEditorModule, ArtemisMarkdownEditorModule], - }) - .compileComponents() - .then(() => { - fixture = TestBed.createComponent(MarkdownEditorComponent); - comp = fixture.componentInstance; - - command = new LinkCommand(); - comp.defaultCommands = [command]; - fixture.detectChanges(); - comp.ngAfterViewInit(); - }); - }); - - it('should add [](http://) on execute', () => { - comp.aceEditorContainer.getEditor().setValue(''); - command.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('[](http://)'); - }); - - it('should remove [](http://) on execute', () => { - comp.aceEditorContainer.getEditor().setValue('[](http://)test'); - command.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('test'); - }); -}); diff --git a/src/test/javascript/spec/component/markdown-editor/markdown-editor-monaco.component.spec.ts b/src/test/javascript/spec/component/markdown-editor/markdown-editor-monaco.component.spec.ts index 6e9ec8921a96..3d1c671dab9a 100644 --- a/src/test/javascript/spec/component/markdown-editor/markdown-editor-monaco.component.spec.ts +++ b/src/test/javascript/spec/component/markdown-editor/markdown-editor-monaco.component.spec.ts @@ -7,7 +7,7 @@ import { ComponentFixture, TestBed, fakeAsync, flush } from '@angular/core/testi import { FormsModule } from '@angular/forms'; import { NgbNavModule, NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; import { MockComponent, MockDirective, MockPipe, MockProvider } from 'ng-mocks'; -import { MarkdownEditorMonacoComponent } from 'app/shared/markdown-editor/monaco/markdown-editor-monaco.component'; +import { MarkdownEditorHeight, MarkdownEditorMonacoComponent } from 'app/shared/markdown-editor/monaco/markdown-editor-monaco.component'; import { MonacoEditorComponent } from 'app/shared/monaco-editor/monaco-editor.component'; import { MonacoColorAction } from 'app/shared/monaco-editor/model/actions/monaco-color.action'; import { MockResizeObserver } from '../../helpers/mocks/service/mock-resize-observer'; @@ -15,7 +15,6 @@ import { CdkDragMove, DragDropModule } from '@angular/cdk/drag-drop'; import { ArtemisSharedModule } from 'app/shared/shared.module'; import { MonacoUrlAction } from 'app/shared/monaco-editor/model/actions/monaco-url.action'; import { MonacoAttachmentAction } from 'app/shared/monaco-editor/model/actions/monaco-attachment.action'; -import { MarkdownEditorHeight } from 'app/shared/markdown-editor/markdown-editor.component'; import { MonacoFormulaAction } from 'app/shared/monaco-editor/model/actions/monaco-formula.action'; import { MonacoTestCaseAction } from 'app/shared/monaco-editor/model/actions/monaco-test-case.action'; import { MonacoTaskAction } from 'app/shared/monaco-editor/model/actions/monaco-task.action'; diff --git a/src/test/javascript/spec/component/markdown-editor/markdown-editor.component.spec.ts b/src/test/javascript/spec/component/markdown-editor/markdown-editor.component.spec.ts deleted file mode 100644 index b89259161e85..000000000000 --- a/src/test/javascript/spec/component/markdown-editor/markdown-editor.component.spec.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule } from '@angular/forms'; -import { MarkdownEditorComponent } from 'app/shared/markdown-editor/markdown-editor.component'; -import { MockComponent, MockDirective, MockPipe, MockProvider } from 'ng-mocks'; -import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; -import { AceEditorComponent } from 'app/shared/markdown-editor/ace-editor/ace-editor.component'; -import { ColorSelectorComponent } from 'app/shared/color-selector/color-selector.component'; -import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; -import { FileUploaderService } from 'app/shared/http/file-uploader.service'; -import { AlertService } from 'app/core/util/alert.service'; -import { NgbNav } from '@ng-bootstrap/ng-bootstrap'; -import { ArtemisTestModule } from '../../test.module'; -import { NegatedTypeCheckPipe } from 'app/shared/pipes/negated-type-check.pipe'; -import { TypeCheckPipe } from 'app/shared/pipes/type-check.pipe'; - -describe('MarkdownEditorComponent', () => { - let fixture: ComponentFixture; - let component: MarkdownEditorComponent; - - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [MockProvider(FileUploaderService), MockProvider(AlertService)], - imports: [FormsModule, NgbNav, ArtemisTestModule, MockDirective(NgbTooltip)], - declarations: [ - MarkdownEditorComponent, - MockComponent(AceEditorComponent), - MockComponent(ColorSelectorComponent), - MockDirective(NgbTooltip), - MockPipe(ArtemisTranslatePipe), - MockPipe(NegatedTypeCheckPipe), - MockPipe(TypeCheckPipe), - ], - }).compileComponents(); - fixture = TestBed.createComponent(MarkdownEditorComponent); - component = fixture.componentInstance; - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('should embed files when at least one file is uploaded', () => { - // Arrange - const files = [new File([], 'file1.png')]; - const event = { - target: { - files: files, - }, - }; - - const embedFilesSpy = jest.spyOn(component, 'embedFiles').mockImplementation(); - - // Act - component.onFileUpload(event as any); - - // Assert - expect(embedFilesSpy).toHaveBeenCalledWith(files); - }); - - it('should not embed files when no file is uploaded', () => { - // Arrange - const event = { - target: { - files: [], - }, - }; - - const embedFilesSpy = jest.spyOn(component, 'embedFiles'); - - // Act - component.onFileUpload(event as any); - - // Assert - expect(embedFilesSpy).not.toHaveBeenCalled(); - }); -}); diff --git a/src/test/javascript/spec/component/markdown-editor/metisReference-commands.spec.ts b/src/test/javascript/spec/component/markdown-editor/metisReference-commands.spec.ts deleted file mode 100644 index 73f8446acc67..000000000000 --- a/src/test/javascript/spec/component/markdown-editor/metisReference-commands.spec.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { AceEditorModule } from 'app/shared/markdown-editor/ace-editor/ace-editor.module'; -import { MarkdownEditorComponent } from 'app/shared/markdown-editor/markdown-editor.component'; -import { ArtemisMarkdownEditorModule } from 'app/shared/markdown-editor/markdown-editor.module'; -import { ArtemisTestModule } from '../../test.module'; -import { MetisService } from 'app/shared/metis/metis.service'; -import { MockMetisService } from '../../helpers/mocks/service/mock-metis-service.service'; -import { ExerciseReferenceCommand } from 'app/shared/markdown-editor/commands/courseArtifactReferenceCommands/exerciseReferenceCommand'; -import { metisExercise, metisLecture, metisLecture2, metisLecture3 } from '../../helpers/sample/metis-sample-data'; -import { LectureAttachmentReferenceCommand } from 'app/shared/markdown-editor/commands/courseArtifactReferenceCommands/lectureAttachmentReferenceCommand'; -import { ReferenceType } from 'app/shared/metis/metis.util'; -import { LectureService } from 'app/lecture/lecture.service'; -import { MockProvider } from 'ng-mocks'; -import { HttpResponse } from '@angular/common/http'; -import { of } from 'rxjs'; -import { Slide } from 'app/entities/lecture-unit/slide.model'; -import { AttachmentUnit } from 'app/entities/lecture-unit/attachmentUnit.model'; - -describe('Exercise Lecture Attachment Reference Commands', () => { - let comp: MarkdownEditorComponent; - let fixture: ComponentFixture; - let exerciseReferenceCommand: ExerciseReferenceCommand; - let lectureReferenceCommand: LectureAttachmentReferenceCommand; - - let metisService: MetisService; - let lectureService: LectureService; - let findLectureWithDetailsSpy: jest.SpyInstance; - let findAllLecturesWithDetailsSpy: jest.SpyInstance; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ArtemisTestModule, AceEditorModule, ArtemisMarkdownEditorModule], - providers: [{ provide: MetisService, useClass: MockMetisService }, MockProvider(LectureService)], - }) - .compileComponents() - .then(() => { - fixture = TestBed.createComponent(MarkdownEditorComponent); - comp = fixture.componentInstance; - metisService = TestBed.inject(MetisService); - lectureService = TestBed.inject(LectureService); - findLectureWithDetailsSpy = jest.spyOn(lectureService, 'findWithDetailsWithSlides'); - findAllLecturesWithDetailsSpy = jest.spyOn(lectureService, 'findAllByCourseIdWithSlides'); - }); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('should initialize exercise reference command correctly', () => { - exerciseReferenceCommand = new ExerciseReferenceCommand(metisService); - expect(exerciseReferenceCommand.getValues()).toEqual( - metisService.getCourse().exercises!.map((exercise) => ({ id: exercise.id!.toString(), value: exercise.title!, type: exercise.type })), - ); - }); - - it('should initialize lecture attachment reference command correctly', () => { - const attachmentUnit = new AttachmentUnit(); - attachmentUnit.id = 3; - const slide = new Slide(); - slide.id = 3; - slide.slideNumber = 3; - slide.slideImagePath = 'directory/attachments/slides/Metis-Slide-3.png'; - attachmentUnit.slides?.push(slide); - metisLecture3.lectureUnits?.push(attachmentUnit); - const returnValue = of(new HttpResponse({ body: [metisLecture, metisLecture2, metisLecture3], status: 200 })); - - findAllLecturesWithDetailsSpy.mockReturnValue(returnValue); - lectureReferenceCommand = new LectureAttachmentReferenceCommand(metisService, lectureService); - expect(findAllLecturesWithDetailsSpy).toHaveBeenCalledOnce(); - - expect(lectureReferenceCommand.getValues()).toEqual([ - { - id: metisLecture.id!.toString(), - value: metisLecture.title!, - type: ReferenceType.LECTURE, - elements: metisLecture.attachments?.map((attachment: any) => ({ - id: attachment.id!.toString(), - value: attachment.name!, - courseArtifactType: ReferenceType.ATTACHMENT, - })), - }, - { - id: metisLecture2.id!.toString(), - value: metisLecture2.title!, - type: ReferenceType.LECTURE, - elements: metisLecture2.attachments?.map((attachment: any) => ({ - id: attachment.id!.toString(), - value: attachment.name!, - courseArtifactType: ReferenceType.ATTACHMENT, - })), - attachmentUnits: metisLecture2.lectureUnits?.map((unit: any) => { - return { - id: unit.id!.toString(), - value: unit.name!, - slides: unit.slides - ?.map((slide: Slide) => { - return { - id: slide.id!.toString(), - slideNumber: slide.slideNumber!, - slideImagePath: slide.slideImagePath!, - courseArtifactType: ReferenceType.SLIDE, - }; - }) - .sort((a: Slide, b: Slide) => a.slideNumber! - b.slideNumber!), - courseArtifactType: ReferenceType.ATTACHMENT_UNITS, - }; - }), - }, - { - id: metisLecture3.id!.toString(), - value: metisLecture3.title!, - type: ReferenceType.LECTURE, - elements: metisLecture3.attachments?.map((attachment: any) => ({ - id: attachment.id!.toString(), - value: attachment.name!, - courseArtifactType: ReferenceType.ATTACHMENT, - })), - attachmentUnits: metisLecture3.lectureUnits?.map((unit: any) => { - return { - id: unit.id!.toString(), - value: unit.name!, - slides: unit.slides - ?.map((slide: Slide) => { - return { - id: slide.id!.toString(), - slideNumber: slide.slideNumber!, - slideImagePath: slide.slideImagePath!, - courseArtifactType: ReferenceType.SLIDE, - }; - }) - .sort((a: Slide, b: Slide) => a.slideNumber! - b.slideNumber!), - courseArtifactType: ReferenceType.ATTACHMENT_UNITS, - }; - }), - }, - ]); - }); - - it('should insert correct reference link for exercise to markdown editor on execute', () => { - exerciseReferenceCommand = new ExerciseReferenceCommand(metisService); - - comp.defaultCommands = [exerciseReferenceCommand]; - fixture.detectChanges(); - - comp.aceEditorContainer.getEditor().setValue(''); - - const referenceRouterLinkToExercise = `[${metisExercise.type}]${metisExercise.title}(${metisService.getLinkForExercise(metisExercise.id!.toString())})[/${ - metisExercise.type - }]`; - exerciseReferenceCommand.execute(metisExercise.id!.toString()); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe(referenceRouterLinkToExercise); - }); - - it('should insert correct reference link for lecture to markdown editor on execute', () => { - const returnValue = of(new HttpResponse({ body: [metisLecture], status: 200 })); - findAllLecturesWithDetailsSpy.mockReturnValue(returnValue); - lectureReferenceCommand = new LectureAttachmentReferenceCommand(metisService, lectureService); - expect(findAllLecturesWithDetailsSpy).toHaveBeenCalledOnce(); - - const returnValueDetail = of(new HttpResponse({ body: metisLecture, status: 200 })); - findLectureWithDetailsSpy.mockReturnValue(returnValueDetail); - - comp.defaultCommands = [lectureReferenceCommand]; - fixture.detectChanges(); - - comp.aceEditorContainer.getEditor().setValue(''); - - const referenceRouterLinkToLecture = `[lecture]${metisLecture.title}(${metisService.getLinkForLecture(metisLecture.id!.toString())})[/lecture]`; - lectureReferenceCommand.execute(metisLecture.id!.toString(), ReferenceType.LECTURE); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe(referenceRouterLinkToLecture); - }); - - it('should insert correct reference link for attachment to markdown editor on execute', () => { - const returnValue = of(new HttpResponse({ body: [], status: 200 })); - findAllLecturesWithDetailsSpy.mockReturnValue(returnValue); - lectureReferenceCommand = new LectureAttachmentReferenceCommand(metisService, lectureService); - expect(findAllLecturesWithDetailsSpy).toHaveBeenCalledOnce(); - - const returnValueDetail = of(new HttpResponse({ body: {}, status: 200 })); - findLectureWithDetailsSpy.mockReturnValue(returnValueDetail); - - comp.defaultCommands = [lectureReferenceCommand]; - fixture.detectChanges(); - - comp.aceEditorContainer.getEditor().setValue(''); - - const shortLink = metisLecture.attachments?.first() ? metisLecture.attachments?.first()?.link!.split('attachments/')[1] : ''; - const referenceRouterLinkToLecture = `[attachment]${metisLecture.attachments?.first()?.name}(${shortLink})[/attachment]`; - lectureReferenceCommand.execute(metisLecture.id!.toString(), ReferenceType.ATTACHMENT, metisLecture.attachments?.first()?.id!.toString()); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe(referenceRouterLinkToLecture); - }); - - it('should insert correct reference link for attachment unit to markdown editor on execute', () => { - const returnValue = of(new HttpResponse({ body: [metisLecture3], status: 200 })); - findAllLecturesWithDetailsSpy.mockReturnValue(returnValue); - lectureReferenceCommand = new LectureAttachmentReferenceCommand(metisService, lectureService); - expect(findAllLecturesWithDetailsSpy).toHaveBeenCalledOnce(); - - const returnValueDetail = of(new HttpResponse({ body: metisLecture3, status: 200 })); - findLectureWithDetailsSpy.mockReturnValue(returnValueDetail); - - comp.defaultCommands = [lectureReferenceCommand]; - fixture.detectChanges(); - - comp.aceEditorContainer.getEditor().setValue(''); - - const selectedUnit: AttachmentUnit = metisLecture3.lectureUnits?.first() as AttachmentUnit; - const shortLink = selectedUnit?.attachment ? selectedUnit?.attachment?.link!.split('attachments/')[1] : ''; - const referenceRouterLinkToLectureUnit = `[lecture-unit]${metisLecture3.lectureUnits?.first()?.name}(${shortLink})[/lecture-unit]`; - lectureReferenceCommand.execute(metisLecture3.id!.toString(), ReferenceType.ATTACHMENT_UNITS, undefined, selectedUnit?.id!.toString()); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe(referenceRouterLinkToLectureUnit); - }); - - it('should insert correct reference link for attachment unit SLIDE to markdown editor on execute', () => { - const returnValue = of(new HttpResponse({ body: [metisLecture3], status: 200 })); - findAllLecturesWithDetailsSpy.mockReturnValue(returnValue); - lectureReferenceCommand = new LectureAttachmentReferenceCommand(metisService, lectureService); - expect(findAllLecturesWithDetailsSpy).toHaveBeenCalledOnce(); - - const returnValueDetail = of(new HttpResponse({ body: metisLecture3, status: 200 })); - findLectureWithDetailsSpy.mockReturnValue(returnValueDetail); - lectureReferenceCommand = new LectureAttachmentReferenceCommand(metisService, lectureService); - - comp.defaultCommands = [lectureReferenceCommand]; - fixture.detectChanges(); - - comp.aceEditorContainer.getEditor().setValue(''); - - const selectedUnit: AttachmentUnit = metisLecture3.lectureUnits?.first() as AttachmentUnit; - const selectedSlide: Slide = selectedUnit.slides?.first() as Slide; - const shortLink = selectedSlide.slideImagePath!.split('attachments/')[1]; - // Use a regular expression and the replace() method to remove the file name and last slash - const shortLinkWithoutFileName: string = shortLink.replace(new RegExp(`[^/]*${'.png'}`), '').replace(/\/$/, ''); - const referenceRouterLinkToSlide = `[slide]${metisLecture3.lectureUnits?.first()?.name} Slide ${selectedSlide.slideNumber}(${shortLinkWithoutFileName})[/slide]`; - lectureReferenceCommand.execute(metisLecture3.id!.toString(), ReferenceType.ATTACHMENT_UNITS, undefined, selectedUnit?.id!.toString(), selectedSlide?.id!.toString()); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe(referenceRouterLinkToSlide); - }); -}); diff --git a/src/test/javascript/spec/component/markdown-editor/ordered-list-command.spec.ts b/src/test/javascript/spec/component/markdown-editor/ordered-list-command.spec.ts deleted file mode 100644 index f42c65cba12c..000000000000 --- a/src/test/javascript/spec/component/markdown-editor/ordered-list-command.spec.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { AceEditorModule } from 'app/shared/markdown-editor/ace-editor/ace-editor.module'; -import { MarkdownEditorComponent } from 'app/shared/markdown-editor/markdown-editor.component'; -import { ArtemisMarkdownEditorModule } from 'app/shared/markdown-editor/markdown-editor.module'; -import { ArtemisTestModule } from '../../test.module'; -import { OrderedListCommand } from 'app/shared/markdown-editor/commands/orderedListCommand'; -import { Command } from 'app/shared/markdown-editor/commands/command'; - -describe('OrderedListCommand', () => { - let comp: MarkdownEditorComponent; - let fixture: ComponentFixture; - let command: Command; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ArtemisTestModule, AceEditorModule, ArtemisMarkdownEditorModule], - }) - .compileComponents() - .then(() => { - fixture = TestBed.createComponent(MarkdownEditorComponent); - comp = fixture.componentInstance; - - command = new OrderedListCommand(); - comp.defaultCommands = [command]; - fixture.detectChanges(); - comp.ngAfterViewInit(); - }); - }); - - it('should add ordered list to lines on execute', () => { - comp.aceEditorContainer.getEditor().setValue('line 1\nline 2'); - command.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('1. line 1\n2. line 2'); - }); - - it('should add new ordered list on execute', () => { - comp.aceEditorContainer.getEditor().setValue(''); - command.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('1. '); - }); - - it('should remove ordered list on execute', () => { - comp.aceEditorContainer.getEditor().setValue('1. line 1\n2. line 2'); - command.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('line 1\nline 2'); - }); - - it('should handle empty lines and create new list', () => { - comp.aceEditorContainer.getEditor().setValue('Test\n\nTest'); - command.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('1. Test\n\n2. Test'); - }); - - it('should handle empty lines and remove lists', () => { - comp.aceEditorContainer.getEditor().setValue('1. Test\n2.\n3. Test'); - command.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('Test\n\nTest'); - }); - - it('should handle multiple sequential empty lines and remove lists', () => { - comp.aceEditorContainer.getEditor().setValue('1. Test\n\n3. Test'); - command.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('Test\n\nTest'); - }); - - it('should handle dots in sentences and remove lists', () => { - comp.aceEditorContainer.getEditor().setValue('1. Test with dot at the end.\n2. Test with dot. in the center\n3. .Test with dot at the start'); - command.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('Test with dot at the end.\nTest with dot. in the center\n.Test with dot at the start'); - }); - - it('should handle dots in sentences and create lists', () => { - comp.aceEditorContainer.getEditor().setValue('Test with dot at the end.\nTest with dot. in the center\n.Test with dot at the start'); - command.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('1. Test with dot at the end.\n2. Test with dot. in the center\n3. .Test with dot at the start'); - }); - - it('should handle single empty lines and create list', () => { - comp.aceEditorContainer.getEditor().setValue(''); - command.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('1. '); - }); - - it('should handle single empty lines and remove list', () => { - comp.aceEditorContainer.getEditor().setValue('1. '); - command.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe(''); - }); - - it('should keep whitespaces at the beginning and create list', () => { - comp.aceEditorContainer.getEditor().setValue('Test\n Test'); - command.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('1. Test\n 2. Test'); - }); - - it('should keep whitespaces at the beginning and remove list', () => { - comp.aceEditorContainer.getEditor().setValue('1. Test\n 2. Test'); - command.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('Test\n Test'); - }); -}); diff --git a/src/test/javascript/spec/component/markdown-editor/reference-command.spec.ts b/src/test/javascript/spec/component/markdown-editor/reference-command.spec.ts deleted file mode 100644 index 2cfc5ed19ed6..000000000000 --- a/src/test/javascript/spec/component/markdown-editor/reference-command.spec.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { AceEditorModule } from 'app/shared/markdown-editor/ace-editor/ace-editor.module'; -import { MarkdownEditorComponent } from 'app/shared/markdown-editor/markdown-editor.component'; -import { ArtemisMarkdownEditorModule } from 'app/shared/markdown-editor/markdown-editor.module'; -import { ArtemisTestModule } from '../../test.module'; -import { ReferenceCommand } from 'app/shared/markdown-editor/commands/reference.command'; - -describe('ReferenceCommand', () => { - let comp: MarkdownEditorComponent; - let fixture: ComponentFixture; - let referenceCommand: ReferenceCommand; - - afterEach(() => { - jest.restoreAllMocks(); - }); - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ArtemisTestModule, AceEditorModule, ArtemisMarkdownEditorModule], - }) - .compileComponents() - .then(() => { - fixture = TestBed.createComponent(MarkdownEditorComponent); - comp = fixture.componentInstance; - - referenceCommand = new ReferenceCommand(); - comp.defaultCommands = [referenceCommand]; - fixture.detectChanges(); - comp.ngAfterViewInit(); - }); - }); - - it('should add > Reference on execute when no text is selected', () => { - referenceCommand.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('> Reference'); - }); - - it('should remove > lorem on execute when reference is selected', () => { - comp.aceEditorContainer.getEditor().setValue('> lorem'); - referenceCommand.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('lorem'); - }); - - it('should remove > Reference on execute when reference is selected', () => { - comp.aceEditorContainer.getEditor().setValue('> Reference'); - referenceCommand.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe(''); - }); - - it('should add > on execute when text is selected', () => { - comp.aceEditorContainer.getEditor().setValue('lorem'); - referenceCommand.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('> lorem'); - }); -}); diff --git a/src/test/javascript/spec/component/markdown-editor/underline-command.spec.ts b/src/test/javascript/spec/component/markdown-editor/underline-command.spec.ts deleted file mode 100644 index ef851a0a03d3..000000000000 --- a/src/test/javascript/spec/component/markdown-editor/underline-command.spec.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { AceEditorModule } from 'app/shared/markdown-editor/ace-editor/ace-editor.module'; -import { MarkdownEditorComponent } from 'app/shared/markdown-editor/markdown-editor.component'; -import { ArtemisMarkdownEditorModule } from 'app/shared/markdown-editor/markdown-editor.module'; -import { ArtemisTestModule } from '../../test.module'; -import { UnderlineCommand } from 'app/shared/markdown-editor/commands/underline.command'; - -describe('Underline Command', () => { - let comp: MarkdownEditorComponent; - let fixture: ComponentFixture; - let underlineCommand: UnderlineCommand; - - afterEach(() => { - jest.restoreAllMocks(); - }); - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ArtemisTestModule, AceEditorModule, ArtemisMarkdownEditorModule], - }) - .compileComponents() - .then(() => { - fixture = TestBed.createComponent(MarkdownEditorComponent); - comp = fixture.componentInstance; - - underlineCommand = new UnderlineCommand(); - comp.defaultCommands = [underlineCommand]; - fixture.detectChanges(); - comp.ngAfterViewInit(); - }); - }); - - it('should add brackets on execute when no text is selected', () => { - underlineCommand.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe(''); - }); - - it('should add around selected text on execute when text is selected', () => { - comp.aceEditorContainer.getEditor().setValue('lorem'); - underlineCommand.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('lorem'); - }); - - it('should remove around selected text on execute when text is selected', () => { - comp.aceEditorContainer.getEditor().setValue('lorem'); - underlineCommand.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('lorem'); - }); -}); diff --git a/src/test/javascript/spec/component/markdown-editor/unordered-list-command.spec.ts b/src/test/javascript/spec/component/markdown-editor/unordered-list-command.spec.ts deleted file mode 100644 index e3fade1b6863..000000000000 --- a/src/test/javascript/spec/component/markdown-editor/unordered-list-command.spec.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { AceEditorModule } from 'app/shared/markdown-editor/ace-editor/ace-editor.module'; -import { MarkdownEditorComponent } from 'app/shared/markdown-editor/markdown-editor.component'; -import { ArtemisMarkdownEditorModule } from 'app/shared/markdown-editor/markdown-editor.module'; -import { ArtemisTestModule } from '../../test.module'; -import { UnorderedListCommand } from 'app/shared/markdown-editor/commands/unorderedListCommand'; -import { Command } from 'app/shared/markdown-editor/commands/command'; - -describe('UnorderedListCommand', () => { - let comp: MarkdownEditorComponent; - let fixture: ComponentFixture; - let command: Command; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ArtemisTestModule, AceEditorModule, ArtemisMarkdownEditorModule], - }) - .compileComponents() - .then(() => { - fixture = TestBed.createComponent(MarkdownEditorComponent); - comp = fixture.componentInstance; - - command = new UnorderedListCommand(); - comp.defaultCommands = [command]; - fixture.detectChanges(); - comp.ngAfterViewInit(); - }); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('should add ordered list to lines on execute', () => { - comp.aceEditorContainer.getEditor().setValue('line 1\nline 2'); - command.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('- line 1\n- line 2'); - }); - - it('should add new ordered list on execute', () => { - comp.aceEditorContainer.getEditor().setValue(''); - command.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('- '); - }); - - it('should remove ordered list on execute', () => { - comp.aceEditorContainer.getEditor().setValue('- line 1\n- line 2'); - command.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('line 1\nline 2'); - }); - - it('should handle empty lines and create new list', () => { - comp.aceEditorContainer.getEditor().setValue('Test\n\nTest'); - command.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('- Test\n\n- Test'); - }); - - it('should handle empty lines and remove lists', () => { - comp.aceEditorContainer.getEditor().setValue('- Test\n- \n- Test'); - command.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('Test\n\nTest'); - }); - - it('should handle multiple sequential empty lines and remove lists', () => { - comp.aceEditorContainer.getEditor().setValue('- Test\n\n- Test'); - command.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('Test\n\nTest'); - }); - - it('should handle dots in sentences and remove lists', () => { - comp.aceEditorContainer.getEditor().setValue('- Test with dash at the end-\n- Test with dash- in the center\n- -Test with dash at the start'); - command.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('Test with dash at the end-\nTest with dash- in the center\n-Test with dash at the start'); - }); - - it('should handle dots in sentences and create lists', () => { - comp.aceEditorContainer.getEditor().setValue('Test with dash at the end-\nTest with dash- in the center\n-Test with dash at the start'); - command.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('- Test with dash at the end-\n- Test with dash- in the center\n- -Test with dash at the start'); - }); - - it('should handle single empty lines and remove list', () => { - comp.aceEditorContainer.getEditor().setValue('- '); - command.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe(''); - }); - - it('should keep whitespaces at the beginning and create list', () => { - comp.aceEditorContainer.getEditor().setValue('Test\n Test'); - command.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('- Test\n - Test'); - }); - - it('should keep whitespaces at the beginning and remove list', () => { - comp.aceEditorContainer.getEditor().setValue('- Test\n - Test'); - command.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('Test\n Test'); - }); - - it('should handle single dash and remove list', () => { - comp.aceEditorContainer.getEditor().setValue('- -'); - command.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('-'); - }); - - it('should handle single dash and create list', () => { - comp.aceEditorContainer.getEditor().setValue('-'); - command.execute(); - expect(comp.aceEditorContainer.getEditor().getValue()).toBe('- -'); - }); -}); diff --git a/src/test/javascript/spec/component/markdown-editor/userMentionCommand.spec.ts b/src/test/javascript/spec/component/markdown-editor/userMentionCommand.spec.ts deleted file mode 100644 index 853013fdb62c..000000000000 --- a/src/test/javascript/spec/component/markdown-editor/userMentionCommand.spec.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { MetisService } from 'app/shared/metis/metis.service'; -import { ConversationUserDTO } from 'app/entities/metis/conversation/conversation-user-dto.model'; -import { HttpResponse } from '@angular/common/http'; -import { of } from 'rxjs'; -import { UserMentionCommand } from 'app/shared/markdown-editor/commands/courseArtifactReferenceCommands/userMentionCommand'; -import { CourseManagementService } from 'app/course/manage/course-management.service'; -import { SelectableItem } from 'app/shared/markdown-editor/commands/interactiveSearchCommand'; - -describe('UserMentionCommand', () => { - let userMentionCommand: UserMentionCommand; - let courseManagementServiceMock: Partial; - let metisServiceMock: Partial; - let aceEditorMock: any; - let selectWithSearchComponent: any; - - beforeEach(() => { - selectWithSearchComponent = { - open: () => {}, - updateSearchTerm: () => {}, - close: () => {}, - }; - - courseManagementServiceMock = { - searchMembersForUserMentions: () => - of( - new HttpResponse({ - body: [ - { name: 'User 1', login: 'user1' }, - { name: 'User 2', login: 'user2' }, - ], - }), - ), - }; - - metisServiceMock = { - getCourse: () => ({ id: 123 }), - }; - - aceEditorMock = { - command: undefined, - commands: { - addCommand: (obj: any) => { - aceEditorMock.command = obj; - }, - }, - execCommand: () => {}, - getCursorPosition: () => ({ row: 0, column: 0 }), - focus: () => {}, - session: { - getDocument: () => ({ - removeInLine: () => {}, - }), - getLine: () => '', - }, - insert: () => {}, - renderer: { - textToScreenCoordinates: () => {}, - }, - }; - - // Create an instance of UserMentionCommand with mock services - userMentionCommand = new UserMentionCommand(courseManagementServiceMock as CourseManagementService, metisServiceMock as MetisService); - userMentionCommand.setSelectWithSearchComponent(selectWithSearchComponent); - }); - - it('should create an instance of UserMentionCommand', () => { - expect(userMentionCommand).toBeTruthy(); - }); - - it('should perform a user search', () => { - const searchTerm = 'user'; - - const searchMembersForUserMentionsSpy = jest.spyOn(courseManagementServiceMock, 'searchMembersForUserMentions'); - - userMentionCommand.performSearch(searchTerm).subscribe((response) => { - expect(response.body).toEqual([ - { name: 'User 1', login: 'user1' }, - { name: 'User 2', login: 'user2' }, - ]); - }); - - expect(searchMembersForUserMentionsSpy).toHaveBeenCalledExactlyOnceWith(123, searchTerm); - }); - - it('should insert selection', () => { - userMentionCommand.setEditor(aceEditorMock); - - const focusSpy = jest.spyOn(aceEditorMock, 'focus'); - - // Simulate open selection menu via triggering command - aceEditorMock.command.exec(aceEditorMock); - - userMentionCommand.insertSelection({ name: 'User 1', login: 'user1' } as SelectableItem); - - // the editor is focues twice: Once for the execution of the command, once after the text insertion - expect(focusSpy).toHaveBeenCalledTimes(2); - }); - - it('should not be able to call command twice while active', () => { - userMentionCommand.setEditor(aceEditorMock); - - const openSpy = jest.spyOn(selectWithSearchComponent, 'open'); - - // Simulate open selection menu via triggering command - aceEditorMock.command.exec(aceEditorMock); - aceEditorMock.command.exec(aceEditorMock); - - expect(openSpy).toHaveBeenCalledOnce(); - }); - - it('should add command when setting editor', () => { - const addCommandSpy = jest.spyOn(aceEditorMock.commands, 'addCommand'); - - userMentionCommand.setEditor(aceEditorMock); - - expect(addCommandSpy).toHaveBeenCalledOnce(); - }); - - it('should execute the command', () => { - userMentionCommand.setEditor(aceEditorMock); - - const execCommandSpy = jest.spyOn(aceEditorMock, 'execCommand'); - - userMentionCommand.execute(); - - expect(execCommandSpy).toHaveBeenCalledExactlyOnceWith('@'); - }); - - it('should calculate screen coordinates', () => { - userMentionCommand.setEditor(aceEditorMock); - - const textToScreenCoordinatesSpy = jest.spyOn(aceEditorMock.renderer, 'textToScreenCoordinates'); - - userMentionCommand.getCursorScreenPosition(); - - expect(textToScreenCoordinatesSpy).toHaveBeenCalledOnce(); - }); - - it('should update search term correctly based on cursor position', () => { - userMentionCommand.setEditor(aceEditorMock); - aceEditorMock.command.exec(aceEditorMock); - aceEditorMock.getCursorPosition = jest.fn(() => ({ row: 0, column: 10 })); - aceEditorMock.session.getLine = jest.fn(() => 'Hello @user'); - - const updateSearchTermSpy = jest.spyOn(selectWithSearchComponent, 'updateSearchTerm'); - - userMentionCommand.updateSearchTerm(); - - expect(updateSearchTermSpy).toHaveBeenCalledExactlyOnceWith('user'); - }); - - it('should not update search term if menu not open', () => { - userMentionCommand.setEditor(aceEditorMock); - - const updateSearchTermSpy = jest.spyOn(selectWithSearchComponent, 'updateSearchTerm'); - - userMentionCommand.updateSearchTerm(); - - expect(updateSearchTermSpy).not.toHaveBeenCalled(); - }); - - it('should close selectWithSearchComponent if there is no "@" sign', () => { - userMentionCommand.setEditor(aceEditorMock); - aceEditorMock.command.exec(aceEditorMock); - aceEditorMock.getCursorPosition = jest.fn(() => ({ row: 0, column: 6 })); - aceEditorMock.session.getLine = jest.fn(() => 'Hello '); - - const closeSpy = jest.spyOn(selectWithSearchComponent, 'close'); - - userMentionCommand.updateSearchTerm(); - - expect(closeSpy).toHaveBeenCalledOnce(); - }); -}); diff --git a/src/test/javascript/spec/component/modeling-exercise/modeling-exercise-update.component.spec.ts b/src/test/javascript/spec/component/modeling-exercise/modeling-exercise-update.component.spec.ts index b05efdeb91e5..f9963b3f1725 100644 --- a/src/test/javascript/spec/component/modeling-exercise/modeling-exercise-update.component.spec.ts +++ b/src/test/javascript/spec/component/modeling-exercise/modeling-exercise-update.component.spec.ts @@ -27,6 +27,7 @@ import { ExerciseUpdatePlagiarismComponent } from 'app/exercises/shared/plagiari import { NgModel } from '@angular/forms'; import { TeamConfigFormGroupComponent } from 'app/exercises/shared/team-config-form-group/team-config-form-group.component'; import { UMLDiagramType } from '@ls1intum/apollon'; +import { ExerciseCategory } from 'app/entities/exercise-category.model'; describe('ModelingExerciseUpdateComponent', () => { let comp: ModelingExerciseUpdateComponent; @@ -34,7 +35,8 @@ describe('ModelingExerciseUpdateComponent', () => { let service: ModelingExerciseService; let courseService: CourseManagementService; let exerciseService: ExerciseService; - const categories = [{ category: 'testCat' }, { category: 'testCat2' }]; + const categories = [new ExerciseCategory('testCat', undefined), new ExerciseCategory('testCat2', undefined)]; + const categoriesStringified = categories.map((cat) => JSON.stringify(cat)); beforeEach(() => { @@ -262,7 +264,8 @@ describe('ModelingExerciseUpdateComponent', () => { const modelingExercise = new ModelingExercise(UMLDiagramType.ClassDiagram, undefined, undefined); modelingExercise.categories = categories; comp.modelingExercise = modelingExercise; - const newCategories = [{ category: 'newCat1' }, { category: 'newCat2' }]; + const newCategories = [new ExerciseCategory('newCat1', undefined), new ExerciseCategory('newCat2', undefined)]; + comp.updateCategories(newCategories); expect(comp.modelingExercise.categories).toEqual(newCategories); }); @@ -285,7 +288,7 @@ describe('ModelingExerciseUpdateComponent', () => { it('should updateCategories properly by making category available for selection again when removing it', () => { comp.modelingExercise = new ModelingExercise(UMLDiagramType.ClassDiagram, undefined, undefined); comp.exerciseCategories = []; - const newCategories = [{ category: 'Easy' }, { category: 'Hard' }]; + const newCategories = [new ExerciseCategory('Easy', undefined), new ExerciseCategory('Hard', undefined)]; comp.updateCategories(newCategories); diff --git a/src/test/javascript/spec/component/modeling-submission/modeling-submission-team.component.spec.ts b/src/test/javascript/spec/component/modeling-submission/modeling-submission-team.component.spec.ts index c6019160c79c..0f44f1b0e6fd 100644 --- a/src/test/javascript/spec/component/modeling-submission/modeling-submission-team.component.spec.ts +++ b/src/test/javascript/spec/component/modeling-submission/modeling-submission-team.component.spec.ts @@ -1,4 +1,3 @@ -import * as ace from 'brace'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { BehaviorSubject, of, throwError } from 'rxjs'; import { ArtemisTestModule } from '../../test.module'; @@ -47,8 +46,6 @@ import { ExerciseMode } from 'app/entities/exercise.model'; import { SubmissionPatch } from 'app/entities/submission-patch.model'; describe('ModelingSubmissionComponent', () => { - // needed to make sure ace is defined - ace.acequire('ace/ext/modelist.js'); let comp: ModelingSubmissionComponent; let fixture: ComponentFixture; let debugElement: DebugElement; diff --git a/src/test/javascript/spec/component/modeling-submission/modeling-submission.component.spec.ts b/src/test/javascript/spec/component/modeling-submission/modeling-submission.component.spec.ts index 7ab3c3b3d2fa..439db7c5dcb9 100644 --- a/src/test/javascript/spec/component/modeling-submission/modeling-submission.component.spec.ts +++ b/src/test/javascript/spec/component/modeling-submission/modeling-submission.component.spec.ts @@ -1,4 +1,3 @@ -import * as ace from 'brace'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { BehaviorSubject, of, throwError } from 'rxjs'; import { ArtemisTestModule } from '../../test.module'; @@ -45,8 +44,6 @@ import { GradingInstruction } from 'app/exercises/shared/structured-grading-crit import { AlertService } from 'app/core/util/alert.service'; describe('ModelingSubmissionComponent', () => { - // needed to make sure ace is defined - ace.acequire('ace/ext/modelist.js'); let comp: ModelingSubmissionComponent; let fixture: ComponentFixture; let debugElement: DebugElement; diff --git a/src/test/javascript/spec/component/overview/course-exams/course-exams.component.spec.ts b/src/test/javascript/spec/component/overview/course-exams/course-exams.component.spec.ts index cabb62f2ae39..4b6ccb1ca3f1 100644 --- a/src/test/javascript/spec/component/overview/course-exams/course-exams.component.spec.ts +++ b/src/test/javascript/spec/component/overview/course-exams/course-exams.component.spec.ts @@ -5,20 +5,21 @@ import { CourseExamsComponent } from 'app/overview/course-exams/course-exams.com import { Exam } from 'app/entities/exam.model'; import { ArtemisTestModule } from '../../../test.module'; import dayjs from 'dayjs/esm'; -import { MockModule, MockPipe, MockProvider } from 'ng-mocks'; +import { MockDirective, MockModule, MockPipe, MockProvider } from 'ng-mocks'; import { Observable, of } from 'rxjs'; import { ArtemisServerDateService } from 'app/shared/server-date.service'; import { ExamParticipationService } from 'app/exam/participate/exam-participation.service'; import { StudentExam } from 'app/entities/student-exam.model'; import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; import { CourseStorageService } from 'app/course/manage/course-storage.service'; -import { SidebarComponent } from '../../../../../../main/webapp/app/shared/sidebar/sidebar.component'; +import { SidebarComponent } from 'app/shared/sidebar/sidebar.component'; import { SearchFilterComponent } from 'app/shared/search-filter/search-filter.component'; import { SearchFilterPipe } from 'app/shared/pipes/search-filter.pipe'; import { RouterTestingModule } from '@angular/router/testing'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MockRouter } from '../../../helpers/mocks/mock-router'; import { CourseOverviewService } from 'app/overview/course-overview.service'; +import { TranslateDirective } from 'app/shared/language/translate.directive'; describe('CourseExamsComponent', () => { let component: CourseExamsComponent; @@ -100,7 +101,7 @@ describe('CourseExamsComponent', () => { router.navigate.mockImplementation(() => Promise.resolve(true)); TestBed.configureTestingModule({ - imports: [ArtemisTestModule, RouterTestingModule, MockModule(FormsModule), MockModule(ReactiveFormsModule)], + imports: [ArtemisTestModule, RouterTestingModule, MockModule(FormsModule), MockModule(ReactiveFormsModule), MockDirective(TranslateDirective)], declarations: [CourseExamsComponent, SidebarComponent, SearchFilterComponent, MockPipe(ArtemisTranslatePipe), MockPipe(SearchFilterPipe)], providers: [ { provide: Router, useValue: router }, diff --git a/src/test/javascript/spec/component/programming-assessment/code-editor-tutor-assessment-container.component.spec.ts b/src/test/javascript/spec/component/programming-assessment/code-editor-tutor-assessment-container.component.spec.ts index 417004d212de..059cbe0252d6 100644 --- a/src/test/javascript/spec/component/programming-assessment/code-editor-tutor-assessment-container.component.spec.ts +++ b/src/test/javascript/spec/component/programming-assessment/code-editor-tutor-assessment-container.component.spec.ts @@ -1,4 +1,3 @@ -import * as ace from 'brace'; import { ComponentFixture, TestBed, fakeAsync, flush, tick } from '@angular/core/testing'; import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; import { DebugElement } from '@angular/core'; @@ -86,8 +85,6 @@ function addFeedbackAndValidateScore(comp: CodeEditorTutorAssessmentContainerCom } describe('CodeEditorTutorAssessmentContainerComponent', () => { - // needed to make sure ace is defined - ace.acequire('ace/ext/modelist.js'); let comp: CodeEditorTutorAssessmentContainerComponent; let fixture: ComponentFixture; let debugElement: DebugElement; @@ -267,7 +264,7 @@ describe('CodeEditorTutorAssessmentContainerComponent', () => { // Stub const getFilesWithContentStub = jest.spyOn(repositoryFileService, 'getFilesWithContent'); getFilesWithContentStub.mockReturnValue(of(templateFileSessionReturn)); - // Stub for ace editor + // Stub for code editor const getFileStub = jest.spyOn(repositoryFileService, 'getFile'); const fileSubject = new BehaviorSubject({ fileContent: 'new file text' }); getFileStub.mockReturnValue(fileSubject); diff --git a/src/test/javascript/spec/component/programming-exercise/programming-exercise-instruction-analysis.component.spec.ts b/src/test/javascript/spec/component/programming-exercise/programming-exercise-instruction-analysis.component.spec.ts index f20ccd999489..32b0e6a0922f 100644 --- a/src/test/javascript/spec/component/programming-exercise/programming-exercise-instruction-analysis.component.spec.ts +++ b/src/test/javascript/spec/component/programming-exercise/programming-exercise-instruction-analysis.component.spec.ts @@ -1,7 +1,6 @@ import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { DebugElement } from '@angular/core'; -import { TaskCommand } from 'app/shared/markdown-editor/domainCommands/programming-exercise/task.command'; import { triggerChanges } from '../../helpers/utils/general.utils'; import { TaskCountWarningComponent } from 'app/exercises/programming/manage/instructions-editor/analysis/task-count-warning/task-count-warning.component'; import { ProgrammingExerciseInstructionAnalysisComponent } from 'app/exercises/programming/manage/instructions-editor/analysis/programming-exercise-instruction-analysis.component'; @@ -11,6 +10,7 @@ import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { MockProgrammingExerciseInstructionAnalysisService } from '../../helpers/mocks/service/mock-programming-exericse-instruction-analysis.service'; import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; +import { MonacoTaskAction } from 'app/shared/monaco-editor/model/actions/monaco-task.action'; describe('ProgrammingExerciseInstructionInstructorAnalysis', () => { let comp: ProgrammingExerciseInstructionAnalysisComponent; @@ -20,8 +20,7 @@ describe('ProgrammingExerciseInstructionInstructorAnalysis', () => { const testCaseOkId = 'instruction_analysis_test-case-ok'; const testCaseIssuesId = 'instruction_analysis_test-case-issues'; - const taskCommand = new TaskCommand(); - const taskRegex = taskCommand.getTagRegex('g'); + const taskRegex = MonacoTaskAction.GLOBAL_TASK_REGEX; const exerciseTestCases = ['test1', 'test2', 'test6', 'test7']; const problemStatement = '1. [task][SortStrategy Interface](test1,test2) \n 2. [task][SortStrategy Interface](test3) \n lorem ipsum \n lorem \n 3. [task][SortStrategy Interface](test2,test4)'; diff --git a/src/test/javascript/spec/component/programming-exercise/programming-exercise-update.component.spec.ts b/src/test/javascript/spec/component/programming-exercise/programming-exercise-update.component.spec.ts index df1e7b8924b0..c4f40f4b79dc 100644 --- a/src/test/javascript/spec/component/programming-exercise/programming-exercise-update.component.spec.ts +++ b/src/test/javascript/spec/component/programming-exercise/programming-exercise-update.component.spec.ts @@ -975,7 +975,7 @@ describe('ProgrammingExerciseUpdateComponent', () => { fixture.detectChanges(); tick(); - const categories = [new ExerciseCategory()]; + const categories = [new ExerciseCategory(undefined, undefined)]; expect(comp.exerciseCategories).toBeUndefined(); comp.updateCategories(categories); expect(comp.exerciseCategories).toBe(categories); diff --git a/src/test/javascript/spec/component/quiz-exercise/quiz-exercise-update.component.spec.ts b/src/test/javascript/spec/component/quiz-exercise/quiz-exercise-update.component.spec.ts index 60940086d9d3..a7e1309fcea0 100644 --- a/src/test/javascript/spec/component/quiz-exercise/quiz-exercise-update.component.spec.ts +++ b/src/test/javascript/spec/component/quiz-exercise/quiz-exercise-update.component.spec.ts @@ -40,8 +40,9 @@ import { MockProvider } from 'ng-mocks'; import { Duration } from 'app/exercises/quiz/manage/quiz-exercise-interfaces'; import { QuizQuestionListEditComponent } from 'app/exercises/quiz/manage/quiz-question-list-edit.component'; import { MockNgbModalService } from '../../helpers/mocks/service/mock-ngb-modal.service'; +import { ExerciseCategory } from 'app/entities/exercise-category.model'; -describe('QuizExercise Update Detail Component', () => { +describe('QuizExerciseUpdateComponent', () => { let comp: QuizExerciseUpdateComponent; let exerciseGroupService: ExerciseGroupService; let courseManagementService: CourseManagementService; @@ -447,7 +448,7 @@ describe('QuizExercise Update Detail Component', () => { it('should updateCategories properly by making category available for selection again when removing it', () => { comp.quizExercise = quizExercise; comp.exerciseCategories = []; - const newCategories = [{ category: 'Easy' }, { category: 'Hard' }]; + const newCategories = [new ExerciseCategory('Easy', undefined), new ExerciseCategory('Hard', undefined)]; comp.updateCategories(newCategories); @@ -583,8 +584,8 @@ describe('QuizExercise Update Detail Component', () => { it('should update categories to given categories', () => { resetQuizExercise(); comp.quizExercise = quizExercise; - const exerciseCategory1 = { exerciseId: 1, category: 'category1', color: 'color1' }; - const exerciseCategory2 = { exerciseId: 1, category: 'category1', color: 'color1' }; + const exerciseCategory1 = new ExerciseCategory('category1', 'color1'); + const exerciseCategory2 = new ExerciseCategory('category1', 'color1'); const expected = [exerciseCategory1, exerciseCategory2]; comp.updateCategories([exerciseCategory1, exerciseCategory2]); expect(comp.quizExercise.categories).toEqual(expected); diff --git a/src/test/javascript/spec/component/range-slider.component.spec.ts b/src/test/javascript/spec/component/range-slider.component.spec.ts new file mode 100644 index 000000000000..7e1b747047e0 --- /dev/null +++ b/src/test/javascript/spec/component/range-slider.component.spec.ts @@ -0,0 +1,69 @@ +import { RangeSliderComponent } from 'app/shared/range-slider/range-slider.component'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { MockModule } from 'ng-mocks'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +describe('RangeSliderComponent', () => { + let component: RangeSliderComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [MockModule(FormsModule), MockModule(ReactiveFormsModule)], + declarations: [RangeSliderComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(RangeSliderComponent); + component = fixture.componentInstance; + + component.generalMinValue = 0; + component.generalMaxValue = 100; + component.selectedMinValue = 20; + component.selectedMaxValue = 80; + component.step = 5; + }); + + it('should emit the updated max value', () => { + const emitSpy = jest.spyOn(component.selectedMaxValueChange, 'emit'); + + component.selectedMaxValue = 90; + const event = new Event('input'); + Object.defineProperty(event, 'target', { value: { className: 'range-max', value: 90 } }); + + component.onSelectedMaxValueChanged(event); + expect(emitSpy).toHaveBeenCalledWith(90); + }); + + it('should emit the updated max value rounded up to next selectable value', () => { + const emitSpy = jest.spyOn(component.selectedMaxValueChange, 'emit'); + + component.selectedMaxValue = 11; + const event = new Event('input'); + Object.defineProperty(event, 'target', { value: { className: 'range-max', value: 11 } }); + + component.onSelectedMaxValueChanged(event); + expect(emitSpy).toHaveBeenCalledWith(25); + }); + + it('should emit the updated min value', () => { + const emitSpy = jest.spyOn(component.selectedMinValueChange, 'emit'); + + component.selectedMinValue = 30; + const event = new Event('input'); + Object.defineProperty(event, 'target', { value: { className: 'range-min', value: 30 } }); + + component.onSelectedMinValueChanged(event); + expect(emitSpy).toHaveBeenCalledWith(30); + }); + + it('should emit the updated min value rounded down to next selectable value', () => { + const emitSpy = jest.spyOn(component.selectedMinValueChange, 'emit'); + + component.selectedMinValue = 99; + const event = new Event('input'); + Object.defineProperty(event, 'target', { value: { className: 'range-min', value: 99 } }); + + component.onSelectedMinValueChanged(event); + expect(emitSpy).toHaveBeenCalledWith(75); + }); +}); diff --git a/src/test/javascript/spec/component/shared/code-button.component.spec.ts b/src/test/javascript/spec/component/shared/code-button.component.spec.ts index 499a4e36dc9b..967f51a4e333 100644 --- a/src/test/javascript/spec/component/shared/code-button.component.spec.ts +++ b/src/test/javascript/spec/component/shared/code-button.component.spec.ts @@ -147,7 +147,7 @@ describe('CodeButtonComponent', () => { component.ngOnInit(); tick(); - expect(component.setupSshKeysUrl).toBe(`${window.location.origin}/user-settings/sshSettings`); + expect(component.sshSettingsUrl).toBe(`${window.location.origin}/user-settings/ssh`); expect(component.sshTemplateUrl).toBe(info.sshCloneURLTemplate); expect(component.sshEnabled).toBe(!!info.sshCloneURLTemplate); expect(component.versionControlUrl).toBe(info.versionControlUrl); @@ -158,13 +158,14 @@ describe('CodeButtonComponent', () => { getVcsAccessTokenSpy = jest.spyOn(accountService, 'getVcsAccessToken').mockReturnValue(throwError(() => new HttpErrorResponse({ status: 404, statusText: 'Not found' }))); stubServices(); participation.id = 1; + component.useParticipationVcsAccessToken = true; component.participations = [participation]; component.ngOnChanges(); tick(); component.ngOnInit(); tick(); - expect(component.useVersionControlAccessToken).toBeTrue(); + expect(component.accessTokensEnabled).toBeTrue(); expect(component.user.vcsAccessToken).toEqual(vcsToken); expect(getVcsAccessTokenSpy).toHaveBeenCalled(); expect(createVcsAccessTokenSpy).toHaveBeenCalled(); @@ -173,13 +174,14 @@ describe('CodeButtonComponent', () => { it('should not create new vcsAccessToken when it exists', fakeAsync(() => { participation.id = 1; component.participations = [participation]; + component.useParticipationVcsAccessToken = true; stubServices(); component.ngOnChanges(); tick(); component.ngOnInit(); tick(); - expect(component.useVersionControlAccessToken).toBeTrue(); + expect(component.accessTokensEnabled).toBeTrue(); expect(component.user.vcsAccessToken).toEqual(vcsToken); expect(getVcsAccessTokenSpy).toHaveBeenCalled(); expect(createVcsAccessTokenSpy).not.toHaveBeenCalled(); @@ -207,12 +209,13 @@ describe('CodeButtonComponent', () => { it('should not use ssh when ssh is not enabled (even if useSsh is set)', () => { participation.repositoryUri = `https://gitlab.ase.in.tum.de/scm/ITCPLEASE1/itcplease1-exercise-team1.git`; component.participations = [participation]; - component.useSsh = true; + component.useParticipationVcsAccessToken = true; + component.useSsh = false; component.isTeamParticipation = false; - component.useVersionControlAccessToken = true; - component.useToken = true; + component.accessTokensEnabled = true; component.ngOnInit(); component.ngOnChanges(); + component.useToken = true; const url = component.getHttpOrSshRepositoryUri(); expect(url).toBe(`https://${component.user.login}:**********@gitlab.ase.in.tum.de/scm/ITCPLEASE1/itcplease1-exercise-team1.git`); @@ -258,12 +261,13 @@ describe('CodeButtonComponent', () => { it('should insert the correct token in the repository uri', () => { participation.repositoryUri = `https://${component.user.login}@gitlab.ase.in.tum.de/scm/ITCPLEASE1/itcplease1-exercise-team1.git`; component.participations = [participation]; + component.useParticipationVcsAccessToken = true; component.useSsh = false; - component.useToken = true; component.isTeamParticipation = false; - component.useVersionControlAccessToken = true; + component.accessTokensEnabled = true; component.ngOnInit(); component.ngOnChanges(); + component.useToken = true; // Placeholder is shown let url = component.getHttpOrSshRepositoryUri(); @@ -288,12 +292,13 @@ describe('CodeButtonComponent', () => { it('should add the user login and token to the URL', () => { participation.repositoryUri = `https://gitlab.ase.in.tum.de/scm/ITCPLEASE1/itcplease1-exercise-team1.git`; component.participations = [participation]; + component.useParticipationVcsAccessToken = true; component.useSsh = false; - component.useToken = true; component.isTeamParticipation = false; - component.useVersionControlAccessToken = true; + component.accessTokensEnabled = true; component.ngOnInit(); component.ngOnChanges(); + component.useToken = true; const url = component.getHttpOrSshRepositoryUri(); expect(url).toBe(`https://${component.user.login}:**********@gitlab.ase.in.tum.de/scm/ITCPLEASE1/itcplease1-exercise-team1.git`); @@ -334,6 +339,22 @@ describe('CodeButtonComponent', () => { expect(component.getHttpOrSshRepositoryUri()).toBe('https://user1@gitlab.ase.in.tum.de/scm/ITCPLEASE1/itcplease1-exercise.solution.git'); }); + it('should set wasCopied to true and back to false after 3 seconds on successful copy', () => { + component.ngOnInit(); + jest.useFakeTimers(); + component.onCopyFinished(true); + expect(component.wasCopied).toBeTrue(); + jest.advanceTimersByTime(3000); + expect(component.wasCopied).toBeFalse(); + jest.useRealTimers(); + }); + + it('should not change wasCopied if copy is unsuccessful', () => { + component.ngOnInit(); + component.onCopyFinished(false); + expect(component.wasCopied).toBeFalse(); + }); + it('should fetch and store ssh preference', fakeAsync(() => { stubServices(); @@ -347,33 +368,33 @@ describe('CodeButtonComponent', () => { expect(localStorageUseSshRetrieveStub).toHaveBeenNthCalledWith(1, 'useSsh'); expect(localStorageUseSshObserveStub).toHaveBeenNthCalledWith(1, 'useSsh'); - expect(component.useSsh).toBeFalsy(); + expect(component.useSsh).toBeFalse(); fixture.debugElement.query(By.css('.code-button')).nativeElement.click(); tick(); fixture.debugElement.query(By.css('#useSSHButton')).nativeElement.click(); tick(); expect(localStorageUseSshStoreStub).toHaveBeenNthCalledWith(1, 'useSsh', true); - expect(component.useSsh).toBeTruthy(); + expect(component.useSsh).toBeTrue(); fixture.debugElement.query(By.css('#useHTTPSButton')).nativeElement.click(); tick(); expect(localStorageUseSshStoreStub).toHaveBeenCalledWith('useSsh', false); - expect(component.useSsh).toBeFalsy(); + expect(component.useSsh).toBeFalse(); fixture.debugElement.query(By.css('#useHTTPSWithTokenButton')).nativeElement.click(); tick(); expect(localStorageUseSshStoreStub).toHaveBeenCalledWith('useSsh', false); - expect(component.useSsh).toBeFalsy(); - expect(component.useToken).toBeTruthy(); + expect(component.useSsh).toBeFalse(); + expect(component.useToken).toBeTrue(); localStorageUseSshObserveStubSubject.next(true); tick(); - expect(component.useSsh).toBeTruthy(); + expect(component.useSsh).toBeTrue(); localStorageUseSshObserveStubSubject.next(false); tick(); - expect(component.useSsh).toBeFalsy(); + expect(component.useSsh).toBeFalse(); })); it.each([ @@ -420,7 +441,7 @@ describe('CodeButtonComponent', () => { guidedTourSettings: [], login: 'edx_userLogin', internal: true, - vcsAccessToken: 'token', + vcsAccessToken: vcsToken, }), ); diff --git a/src/test/javascript/spec/component/shared/exercise-filter/exercise-filter-modal.component.spec.ts b/src/test/javascript/spec/component/shared/exercise-filter/exercise-filter-modal.component.spec.ts new file mode 100644 index 000000000000..3432102e0a9d --- /dev/null +++ b/src/test/javascript/spec/component/shared/exercise-filter/exercise-filter-modal.component.spec.ts @@ -0,0 +1,259 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ExerciseFilterModalComponent } from 'app/shared/exercise-filter/exercise-filter-modal.component'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { MockComponent, MockModule, MockProvider } from 'ng-mocks'; +import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; +import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; +import { CustomExerciseCategoryBadgeComponent } from 'app/shared/exercise-categories/custom-exercise-category-badge/custom-exercise-category-badge.component'; +import { RangeSliderComponent } from 'app/shared/range-slider/range-slider.component'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { RangeFilter } from 'app/types/exercise-filter'; +import { DifficultyLevel, Exercise, ExerciseType, getIcon } from 'app/entities/exercise.model'; +import { ExerciseCategory } from 'app/entities/exercise-category.model'; +import { SidebarCardElement } from 'app/types/sidebar'; +import { Result } from 'app/entities/result.model'; +import { StudentParticipation } from 'app/entities/participation/student-participation.model'; + +const EXERCISE_1 = { categories: [new ExerciseCategory('category1', undefined), new ExerciseCategory('category2', '#1b97ca')], maxPoints: 5, type: ExerciseType.TEXT } as Exercise; +const EXERCISE_2 = { + categories: [new ExerciseCategory('category3', '#0d3cc2'), new ExerciseCategory('category4', '#6ae8ac')], + maxPoints: 5, + type: ExerciseType.PROGRAMMING, +} as Exercise; +const EXERCISE_3 = { + categories: [new ExerciseCategory('category1', undefined), new ExerciseCategory('category4', '#6ae8ac')], + maxPoints: 8, + type: ExerciseType.PROGRAMMING, +} as Exercise; + +const SIDEBAR_CARD_ELEMENT_1 = { + exercise: EXERCISE_1, + type: ExerciseType.TEXT, + difficulty: DifficultyLevel.HARD, + studentParticipation: { results: [{ score: 7.7 } as Result] } as StudentParticipation, +} as SidebarCardElement; +const SIDEBAR_CARD_ELEMENT_2 = { + exercise: EXERCISE_2, + type: ExerciseType.PROGRAMMING, + difficulty: DifficultyLevel.EASY, + studentParticipation: { results: [{ score: 5.0 } as Result] } as StudentParticipation, +} as SidebarCardElement; +const SIDEBAR_CARD_ELEMENT_3 = { + exercise: EXERCISE_3, + type: ExerciseType.PROGRAMMING, + difficulty: DifficultyLevel.EASY, + studentParticipation: { results: [{ score: 82.3 } as Result] } as StudentParticipation, +} as SidebarCardElement; + +const SCORE_FILTER: RangeFilter = { + isDisplayed: true, + filter: { + generalMin: 0, + generalMax: 100, + selectedMin: 0, + selectedMax: 100, + step: 5, + }, +}; + +const POINTS_FILTER: RangeFilter = { + isDisplayed: true, + filter: { + generalMin: 0, + generalMax: 20, + selectedMin: 0, + selectedMax: 20, + step: 1, + }, +}; + +describe('ExerciseFilterModalComponent', () => { + let component: ExerciseFilterModalComponent; + let fixture: ComponentFixture; + let activeModal: NgbActiveModal; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + MockModule(FormsModule), + MockModule(ReactiveFormsModule), + MockModule(FontAwesomeModule), + MockModule(ArtemisSharedCommonModule), + MockModule(ArtemisSharedComponentModule), + ], + declarations: [ExerciseFilterModalComponent, MockComponent(CustomExerciseCategoryBadgeComponent), MockComponent(RangeSliderComponent)], + providers: [MockProvider(NgbActiveModal)], + }).compileComponents(); + + fixture = TestBed.createComponent(ExerciseFilterModalComponent); + component = fixture.componentInstance; + activeModal = TestBed.inject(NgbActiveModal); + + component.exerciseFilters = { + exerciseTypesFilter: { + isDisplayed: true, + options: [ + { name: 'artemisApp.courseStatistics.programming', value: ExerciseType.PROGRAMMING, checked: false, icon: getIcon(ExerciseType.PROGRAMMING) }, + { name: 'artemisApp.courseStatistics.quiz', value: ExerciseType.QUIZ, checked: false, icon: getIcon(ExerciseType.QUIZ) }, + { name: 'artemisApp.courseStatistics.modeling', value: ExerciseType.MODELING, checked: false, icon: getIcon(ExerciseType.MODELING) }, + { name: 'artemisApp.courseStatistics.text', value: ExerciseType.TEXT, checked: false, icon: getIcon(ExerciseType.TEXT) }, + { name: 'artemisApp.courseStatistics.file-upload', value: ExerciseType.FILE_UPLOAD, checked: false, icon: getIcon(ExerciseType.FILE_UPLOAD) }, + ], + }, + difficultyFilter: { + isDisplayed: true, + options: [ + { name: 'artemisApp.exercise.easy', value: DifficultyLevel.EASY, checked: false }, + { name: 'artemisApp.exercise.medium', value: DifficultyLevel.MEDIUM, checked: false }, + { name: 'artemisApp.exercise.hard', value: DifficultyLevel.HARD, checked: false }, + ], + }, + categoryFilter: { + isDisplayed: true, + options: [ + { category: new ExerciseCategory('category1', undefined), searched: false }, + { category: new ExerciseCategory('category2', undefined), searched: false }, + ], + }, + achievedScore: SCORE_FILTER, + achievablePoints: POINTS_FILTER, + }; + + fixture.detectChanges(); + }); + + it('should initialize filters properly', () => { + expect(component.categoryFilter).toEqual(component.exerciseFilters?.categoryFilter); + expect(component.typeFilter).toEqual(component.exerciseFilters?.exerciseTypesFilter); + expect(component.difficultyFilter).toEqual(component.exerciseFilters?.difficultyFilter); + expect(component.achievedScore).toEqual(component.exerciseFilters?.achievedScore); + expect(component.achievablePoints).toEqual(component.exerciseFilters?.achievablePoints); + }); + + describe('should close modal', () => { + it('with button in upper right corner on click', () => { + const closeSpy = jest.spyOn(activeModal, 'close'); + const closeModalSpy = jest.spyOn(component, 'closeModal'); + + const closeButton = fixture.debugElement.query(By.css('.btn-close')); + expect(closeButton).not.toBeNull(); + + closeButton.nativeElement.click(); + expect(closeSpy).toHaveBeenCalledOnce(); + expect(closeModalSpy).toHaveBeenCalledOnce(); + }); + + it('with button in lower right corner on click', () => { + const closeSpy = jest.spyOn(activeModal, 'close'); + const closeModalSpy = jest.spyOn(component, 'closeModal'); + + const cancelButton = fixture.debugElement.query(By.css('button[jhiTranslate="entity.action.cancel"]')); + expect(cancelButton).not.toBeNull(); + + cancelButton.nativeElement.click(); + expect(closeSpy).toHaveBeenCalledOnce(); + expect(closeModalSpy).toHaveBeenCalledOnce(); + }); + }); + + describe('select category', () => { + it('should mark a category as selected when category is found', () => { + expect(component.categoryFilter?.options[0].searched).toBeFalse(); // if it is not false in the beginning we do not test anything here + const onSelectItemSpy = jest.spyOn(component, 'onSelectItem'); + + component.model = 'category1'; + // Simulate selecting an item + const event = { + item: component.selectableCategoryOptions[0], + preventDefault: jest.fn(), + }; + component.onSelectItem(event); + fixture.detectChanges(); + + expect(onSelectItemSpy).toHaveBeenCalledOnce(); + expect(component.categoryFilter?.options[0].searched).toBeTrue(); + expect(component.model).toBeUndefined(); // Clear the input field after selection + }); + + it('should not change category filter when no item is provided', () => { + expect(component.categoryFilter?.options[0].searched).toBeFalse(); // if it is not false in the beginning we do not test anything here + const onSelectItemSpy = jest.spyOn(component, 'onSelectItem'); + + component.model = 'categoryThatIsNotDefinedAndSearchedViaEnter'; + const event = { + item: undefined, + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }; + component.onSelectItem(event); + fixture.detectChanges(); + + expect(onSelectItemSpy).toHaveBeenCalledOnce(); + expect(component.categoryFilter?.options[0].searched).toBeFalse(); + expect(component.model).toBe('categoryThatIsNotDefinedAndSearchedViaEnter'); + }); + }); + + it('should reset all filters when button is clicked', () => { + component.categoryFilter!.options[0].searched = true; + component.categoryFilter!.options[1].searched = true; + component.typeFilter!.options[0].checked = true; + component.typeFilter!.options[1].checked = true; + component.difficultyFilter!.options[0].checked = true; + component.difficultyFilter!.options[1].checked = false; + component.achievablePoints!.filter.selectedMax = 10; + component.achievedScore!.filter.selectedMin = 10; + const resetFilterSpy = jest.spyOn(component, 'clearFilter'); + + const resetButton = fixture.debugElement.query(By.css('span[jhiTranslate="artemisApp.courseOverview.exerciseFilter.clearFilter"]')); + expect(resetButton).not.toBeNull(); + resetButton.nativeElement.click(); + + expect(resetFilterSpy).toHaveBeenCalledOnce(); + expect(component.categoryFilter!.options[0].searched).toBeFalse(); + expect(component.categoryFilter!.options[1].searched).toBeFalse(); + expect(component.typeFilter!.options[0].checked).toBeFalse(); + expect(component.typeFilter!.options[1].checked).toBeFalse(); + expect(component.difficultyFilter!.options[0].checked).toBeFalse(); + expect(component.difficultyFilter!.options[1].checked).toBeFalse(); + expect(component.achievablePoints!.filter.selectedMax).toBe(component.achievablePoints?.filter.generalMax); + expect(component.achievedScore!.filter.selectedMin).toBe(component.achievedScore?.filter.generalMin); + }); + + it('should apply filters, emit the correct sidebar data and close the modal', () => { + component.sidebarData = { + groupByCategory: true, + sidebarType: 'exercise', + groupedData: { + past: { entityData: [SIDEBAR_CARD_ELEMENT_1, SIDEBAR_CARD_ELEMENT_2, SIDEBAR_CARD_ELEMENT_3] }, + }, + ungroupedData: [SIDEBAR_CARD_ELEMENT_1, SIDEBAR_CARD_ELEMENT_2, SIDEBAR_CARD_ELEMENT_3], + }; + component.categoryFilter!.options[0].searched = true; // must have 'category1' + component.typeFilter!.options[0].checked = true; // must be a programming exercise + component.difficultyFilter!.options[0].checked = true; // must be easy + component.achievablePoints!.filter.selectedMax = 10; + component.achievedScore!.filter.selectedMin = 10; + + const filterAppliedEmitSpy = jest.spyOn(component.filterApplied, 'emit'); + const applyFilterSpy = jest.spyOn(component, 'applyFilter'); + const closeModalSpy = jest.spyOn(component, 'closeModal'); + const applyButton = fixture.debugElement.query(By.css('button[jhiTranslate="artemisApp.courseOverview.exerciseFilter.applyFilter"]')); + expect(applyButton).not.toBeNull(); + applyButton.nativeElement.click(); + + expect(applyFilterSpy).toHaveBeenCalledOnce(); + expect(filterAppliedEmitSpy).toHaveBeenCalledOnce(); + expect(filterAppliedEmitSpy).toHaveBeenCalledWith({ + filteredSidebarData: component.sidebarData, + appliedExerciseFilters: component.exerciseFilters, + isFilterActive: true, + }); + /** only {@link EXERCISE_3} fullfills the filter options and should be emitted in the event */ + expect(component.sidebarData.ungroupedData?.length).toBe(1); + + expect(closeModalSpy).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/test/javascript/spec/component/shared/exercise-filter/exercise-filter-modal.helper.spec.ts b/src/test/javascript/spec/component/shared/exercise-filter/exercise-filter-modal.helper.spec.ts new file mode 100644 index 000000000000..23ec9d99c4be --- /dev/null +++ b/src/test/javascript/spec/component/shared/exercise-filter/exercise-filter-modal.helper.spec.ts @@ -0,0 +1,333 @@ +import { ExerciseCategory } from 'app/entities/exercise-category.model'; +import { DifficultyLevel, Exercise, ExerciseType } from 'app/entities/exercise.model'; +import { Result } from 'app/entities/result.model'; +import { StudentParticipation } from 'app/entities/participation/student-participation.model'; +import { SidebarCardElement } from 'app/types/sidebar'; +import { + satisfiesCategoryFilter, + satisfiesDifficultyFilter, + satisfiesFilters, + satisfiesPointsFilter, + satisfiesScoreFilter, +} from 'app/shared/exercise-filter/exercise-filter-modal.helper'; +import { FilterDetails, RangeFilter } from 'app/types/exercise-filter'; + +const EXERCISE_1 = { categories: [new ExerciseCategory('category1', '#691b0b'), new ExerciseCategory('category2', '#1b97ca')], maxPoints: 10, type: ExerciseType.TEXT } as Exercise; +const EXERCISE_2 = { categories: [new ExerciseCategory('category3', '#0d3cc2'), new ExerciseCategory('category4', '#6ae8ac')], maxPoints: 5 } as Exercise; +const EXERCISE_4 = { categories: [new ExerciseCategory('category1', '#691b0b'), new ExerciseCategory('category8', '#1b97ca')], maxPoints: 10 } as Exercise; +const EXERCISE_5 = { maxPoints: 20 } as Exercise; + +const SIDEBAR_CARD_ELEMENT_1 = { + exercise: EXERCISE_1, + type: ExerciseType.TEXT, + difficulty: DifficultyLevel.HARD, + studentParticipation: { results: [{ score: 7.7 } as Result] } as StudentParticipation, +} as SidebarCardElement; +const SIDEBAR_CARD_ELEMENT_2 = { + exercise: EXERCISE_2, + type: ExerciseType.PROGRAMMING, + difficulty: DifficultyLevel.EASY, + studentParticipation: { results: [{ score: 82.3 } as Result] } as StudentParticipation, +} as SidebarCardElement; +const SIDEBAR_CARD_ELEMENT_4 = { exercise: EXERCISE_4 } as SidebarCardElement; + +/** contains duplicated type and difficulty with {@link SIDEBAR_CARD_ELEMENT_2}*/ +const SIDEBAR_CARD_ELEMENT_5 = { exercise: EXERCISE_5, type: ExerciseType.PROGRAMMING, difficulty: DifficultyLevel.EASY } as SidebarCardElement; + +describe('satisfiesDifficultyFilter', () => { + it('should return true if difficulty filter is undefined', () => { + const difficultyFilter = undefined; + + const resultItemWithDifficulty = satisfiesDifficultyFilter(SIDEBAR_CARD_ELEMENT_1, difficultyFilter); + expect(resultItemWithDifficulty).toBeTrue(); + + const resultItemWithoutDifficulty = satisfiesDifficultyFilter(SIDEBAR_CARD_ELEMENT_4, difficultyFilter); + expect(resultItemWithoutDifficulty).toBeTrue(); + }); + + it('should return true if difficulty filter is []', () => { + const difficultyFilter: DifficultyLevel[] = []; + + const resultItemWithDifficulty = satisfiesDifficultyFilter(SIDEBAR_CARD_ELEMENT_1, difficultyFilter); + expect(resultItemWithDifficulty).toBeTrue(); + + const resultItemWithoutDifficulty = satisfiesDifficultyFilter(SIDEBAR_CARD_ELEMENT_4, difficultyFilter); + expect(resultItemWithoutDifficulty).toBeTrue(); + }); + + it('should return true if difficulty is in difficulty filter', () => { + const difficultyFilter = [DifficultyLevel.HARD]; + + const resultItemWithDifficulty = satisfiesDifficultyFilter(SIDEBAR_CARD_ELEMENT_1, difficultyFilter); + expect(resultItemWithDifficulty).toBeTrue(); + }); + + it('should return false if difficulty is NOT in difficulty filter', () => { + const difficultyFilter = [DifficultyLevel.HARD]; + + const resultItemWithDifficulty = satisfiesDifficultyFilter(SIDEBAR_CARD_ELEMENT_2, difficultyFilter); + expect(resultItemWithDifficulty).toBeFalse(); + + const resultItemWithoutDifficulty = satisfiesDifficultyFilter(SIDEBAR_CARD_ELEMENT_4, difficultyFilter); + expect(resultItemWithoutDifficulty).toBeFalse(); + }); +}); + +describe('satisfiesCategoryFilter', () => { + it('should return true if difficulty filter is []', () => { + const categoryFilter: ExerciseCategory[] = []; + + const resultItemWithCategory = satisfiesCategoryFilter(SIDEBAR_CARD_ELEMENT_1, categoryFilter); + expect(resultItemWithCategory).toBeTrue(); + + const resultItemWithoutCategory = satisfiesCategoryFilter(SIDEBAR_CARD_ELEMENT_5, categoryFilter); + expect(resultItemWithoutCategory).toBeTrue(); + }); + + it('should return true category is included in difficulty filter', () => { + const categoryFilter = [new ExerciseCategory('category1', '#691b0b')]; + + const resultItemWithMatchingCategory = satisfiesCategoryFilter(SIDEBAR_CARD_ELEMENT_1, categoryFilter); + expect(resultItemWithMatchingCategory).toBeTrue(); + }); + + it('should return false if difficulty is NOT in difficulty filter', () => { + const categoryFilter = [new ExerciseCategory('notExistingCategory', '#691b0b')]; + + const resultItemWithCategory = satisfiesCategoryFilter(SIDEBAR_CARD_ELEMENT_1, categoryFilter); + expect(resultItemWithCategory).toBeFalse(); + + const resultItemWithoutCategory = satisfiesCategoryFilter(SIDEBAR_CARD_ELEMENT_5, categoryFilter); + expect(resultItemWithoutCategory).toBeFalse(); + }); +}); + +describe('satisfiesScoreFilter', () => { + it('should return true if score filter is undefined', () => { + const scoreFilter = undefined; + + const resultItemWithScore = satisfiesScoreFilter(SIDEBAR_CARD_ELEMENT_1, true, scoreFilter); + expect(resultItemWithScore).toBeTrue(); + + const resultItemWithoutScore = satisfiesScoreFilter(SIDEBAR_CARD_ELEMENT_4, true, scoreFilter); + expect(resultItemWithoutScore).toBeTrue(); + }); + + it('should return true if score filter is not applied', () => { + const scoreFilter: RangeFilter = { + isDisplayed: true, + filter: { + selectedMin: 0, + selectedMax: 1, + generalMin: 0, + generalMax: 1, + step: 1, + }, + }; + + const resultItemWithScore = satisfiesScoreFilter(SIDEBAR_CARD_ELEMENT_1, false, scoreFilter); + expect(resultItemWithScore).toBeTrue(); + + const resultItemWithoutScore = satisfiesScoreFilter(SIDEBAR_CARD_ELEMENT_4, false, scoreFilter); + expect(resultItemWithoutScore).toBeTrue(); + }); + + it('should return true if score is in score filter', () => { + const scoreFilter: RangeFilter = { + isDisplayed: true, + filter: { + selectedMin: 5, + selectedMax: 10, + generalMin: 0, + generalMax: 80, + step: 1, + }, + }; + + const resultItemWithScore = satisfiesScoreFilter(SIDEBAR_CARD_ELEMENT_1, true, scoreFilter); + expect(resultItemWithScore).toBeTrue(); + }); + + it('should return false if score is NOT in score filter', () => { + const scoreFilter: RangeFilter = { + isDisplayed: true, + filter: { + selectedMin: 20, + selectedMax: 30, + generalMin: 0, + generalMax: 80, + step: 1, + }, + }; + + const resultItemWithScore = satisfiesScoreFilter(SIDEBAR_CARD_ELEMENT_1, true, scoreFilter); + expect(resultItemWithScore).toBeFalse(); + + const resultItemWithoutScore = satisfiesScoreFilter(SIDEBAR_CARD_ELEMENT_4, true, scoreFilter); + expect(resultItemWithoutScore).toBeFalse(); + }); + + it('should return true if score of participation is not defined (not participated) and lower bound is 0', () => { + const scoreFilter: RangeFilter = { + isDisplayed: true, + filter: { + selectedMin: 0, + selectedMax: 10, + generalMin: 0, + generalMax: 80, + step: 1, + }, + }; + const resultItemWithScore = satisfiesScoreFilter(SIDEBAR_CARD_ELEMENT_4, true, scoreFilter); + expect(resultItemWithScore).toBeTrue(); + }); + + it('should return false if score of participation is not defined (not participated) and lower bound is NOT 0', () => { + const scoreFilter: RangeFilter = { + isDisplayed: true, + filter: { + selectedMin: 1, + selectedMax: 10, + generalMin: 0, + generalMax: 80, + step: 1, + }, + }; + const resultItemWithScore = satisfiesScoreFilter(SIDEBAR_CARD_ELEMENT_4, true, scoreFilter); + expect(resultItemWithScore).toBeFalse(); + }); +}); + +describe('satisfiesPointsFilter', () => { + it('should return true if points filter is undefined', () => { + const pointsFilter = undefined; + + const resultItemWithPoints = satisfiesPointsFilter(SIDEBAR_CARD_ELEMENT_1, true, pointsFilter); + expect(resultItemWithPoints).toBeTrue(); + + const resultItemWithoutPoints = satisfiesPointsFilter(SIDEBAR_CARD_ELEMENT_4, true, pointsFilter); + expect(resultItemWithoutPoints).toBeTrue(); + }); + + it('should return true if points filter is not applied', () => { + const pointsFilter: RangeFilter = { + isDisplayed: true, + filter: { + selectedMin: 0, + selectedMax: 1, + generalMin: 0, + generalMax: 1, + step: 1, + }, + }; + + const resultItemWithPoints = satisfiesPointsFilter(SIDEBAR_CARD_ELEMENT_1, false, pointsFilter); + expect(resultItemWithPoints).toBeTrue(); + + const resultItemWithoutPoints = satisfiesPointsFilter(SIDEBAR_CARD_ELEMENT_4, false, pointsFilter); + expect(resultItemWithoutPoints).toBeTrue(); + }); + + it('should return true if points is in points filter', () => { + const pointsFilter: RangeFilter = { + isDisplayed: true, + filter: { + selectedMin: 9, + selectedMax: 10, + generalMin: 0, + generalMax: 80, + step: 1, + }, + }; + + const resultItemWithPoints = satisfiesPointsFilter(SIDEBAR_CARD_ELEMENT_1, true, pointsFilter); + expect(resultItemWithPoints).toBeTrue(); + }); + + it('should return false if points is NOT in points filter', () => { + const pointsFilter: RangeFilter = { + isDisplayed: true, + filter: { + selectedMin: 11, + selectedMax: 12, + generalMin: 0, + generalMax: 80, + step: 1, + }, + }; + + const resultItemWithPoints = satisfiesPointsFilter(SIDEBAR_CARD_ELEMENT_1, true, pointsFilter); + expect(resultItemWithPoints).toBeFalse(); + + const resultItemWithoutPoints = satisfiesPointsFilter(SIDEBAR_CARD_ELEMENT_4, true, pointsFilter); + expect(resultItemWithoutPoints).toBeFalse(); + }); +}); + +describe('satisfiesFilters', () => { + it('should return true if item satisfies filters', () => { + const filter: FilterDetails = { + selectedCategories: [new ExerciseCategory('category1', '#691b0b')], + searchedTypes: [ExerciseType.TEXT], + searchedDifficulties: [DifficultyLevel.HARD], + isScoreFilterApplied: false, + isPointsFilterApplied: false, + achievedScore: { + isDisplayed: true, + filter: { + selectedMin: 5, + selectedMax: 10, + generalMin: 0, + generalMax: 80, + step: 1, + }, + }, + achievablePoints: { + isDisplayed: true, + filter: { + selectedMin: 9, + selectedMax: 10, + generalMin: 0, + generalMax: 80, + step: 1, + }, + }, + }; + const resultItem = satisfiesFilters(SIDEBAR_CARD_ELEMENT_1, filter); + + expect(resultItem).toBeTrue(); + }); + + it('should return false if item does not satisfy the score filter', () => { + const filter: FilterDetails = { + selectedCategories: [new ExerciseCategory('category1', '#691b0b')], + searchedTypes: [ExerciseType.TEXT], + searchedDifficulties: [DifficultyLevel.HARD], + isScoreFilterApplied: false, + isPointsFilterApplied: false, + achievedScore: { + isDisplayed: true, + filter: { + selectedMin: 69, + selectedMax: 70, + generalMin: 0, + generalMax: 80, + step: 1, + }, + }, + achievablePoints: { + isDisplayed: true, + filter: { + selectedMin: 9, + selectedMax: 10, + generalMin: 0, + generalMax: 80, + step: 1, + }, + }, + }; + const resultItem = satisfiesFilters(SIDEBAR_CARD_ELEMENT_1, filter); + + expect(resultItem).toBeTrue(); + }); +}); diff --git a/src/test/javascript/spec/component/shared/metis/postings-markdown-editor/select-with-search.component.spec.ts b/src/test/javascript/spec/component/shared/metis/postings-markdown-editor/select-with-search.component.spec.ts deleted file mode 100644 index a8060e7fa81b..000000000000 --- a/src/test/javascript/spec/component/shared/metis/postings-markdown-editor/select-with-search.component.spec.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; -import { HttpClientTestingModule } from '@angular/common/http/testing'; -import { of, throwError } from 'rxjs'; -import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; -import { faAt } from '@fortawesome/free-solid-svg-icons'; - -import { SelectWithSearchComponent } from 'app/shared/markdown-editor/select-with-search/select-with-search.component'; -import { InteractiveSearchCommand } from 'app/shared/markdown-editor/commands/interactiveSearchCommand'; -import { AlertService } from 'app/core/util/alert.service'; -import { MockProvider } from 'ng-mocks'; -import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; -import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'; -import { SimpleChange } from '@angular/core'; - -describe('SelectWithSearchComponent', () => { - let component: SelectWithSearchComponent; - let fixture: ComponentFixture; - let alertService: AlertService; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - declarations: [SelectWithSearchComponent], - imports: [HttpClientTestingModule, NgbDropdownModule, FontAwesomeModule], - providers: [MockProvider(AlertService)], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(SelectWithSearchComponent); - component = fixture.componentInstance; - alertService = TestBed.inject(AlertService); - const testItems = [{ name: 'test 1' }, { name: 'test 2' }]; - const command: InteractiveSearchCommand = { - setSelectWithSearchComponent: () => {}, - performSearch: (searchTerm: string) => { - const filteredItems = testItems.filter((item) => item.name.includes(searchTerm)); - return of(new HttpResponse({ body: filteredItems })); - }, - buttonIcon: faAt, - execute: () => { - component.open(); - }, - insertSelection: () => {}, - getCursorScreenPosition: () => { - return { pageX: 0, pageY: 0 }; - }, - updateSearchTerm: () => {}, - } as any; - component.command = command; - fixture.detectChanges(); - }); - - afterEach(() => { - fixture.destroy(); - }); - - it('should initialize and subscribe to search$', fakeAsync(() => { - expect(component.values).toEqual([]); - expect(component.selectedValue).toBeUndefined(); - - // Simulate search input - component.updateSearchTerm('test 1', true); - tick(1); - expect(component.values).toEqual([{ name: 'test 1' }]); - })); - - it('should handle errors when performing search', fakeAsync(() => { - const command: InteractiveSearchCommand = { - setSelectWithSearchComponent: () => {}, - performSearch: () => throwError(() => new HttpErrorResponse({ status: 400 })), - buttonIcon: faAt, - insertSelection: () => {}, - } as any; - - component.command = command; - fixture.detectChanges(); - - const alertSpy = jest.spyOn(alertService, 'error'); - - component.updateSearchTerm('test', true); - tick(1); - - expect(alertSpy).toHaveBeenCalledOnce(); - })); - - it('should toggle menu when clicking button', () => { - // Find the menu button in the fixture - const menuButton = fixture.debugElement.nativeElement.querySelector('button.btn.btn-sm.py-0'); - - const commandExecuteSpy = jest.spyOn(component.command, 'execute'); - const menuCloseSpy = jest.spyOn(component, 'close'); - - // Click button to open menu - menuButton.click(); - fixture.detectChanges(); - - expect(commandExecuteSpy).toHaveBeenCalled(); - - // Click button to close menu - menuButton.click(); - fixture.detectChanges(); - - expect(menuCloseSpy).toHaveBeenCalled(); - }); - - it('should open and close the menu', () => { - const handleMenuOpenSpy = jest.spyOn(component, 'handleMenuOpen'); - const handleMenuClosedSpy = jest.spyOn(component, 'handleMenuClosed'); - - // Click button to open menu - component.open(); - fixture.detectChanges(); - - // Assert that the handleMenuOpen method was called - expect(handleMenuOpenSpy).toHaveBeenCalledOnce(); - expect(component.dropdown.isOpen()).toBeTrue(); - - // Click button to close menu - component.close(); - fixture.detectChanges(); - - // Assert that the handleMenuClosed method was called - expect(handleMenuClosedSpy).toHaveBeenCalledOnce(); - expect(component.dropdown.isOpen()).toBeFalse(); - }); - - it('should set the selectedValue and close the menu when calling setSelection', () => { - // Simulate some values in the component - component.values = [{ name: 'Item 1' }, { name: 'Item 2' }]; - fixture.detectChanges(); - - component.setSelection({ name: 'Item 1' }); - - expect(component.selectedValue).toEqual({ name: 'Item 1' }); - expect(component.dropdown.isOpen()).toBeFalse(); - }); - - it('searchTerm updates on changes', () => { - component.editorContentString = 'test'; - fixture.detectChanges(); - - const updateSearchTermSpy = jest.spyOn(component.command, 'updateSearchTerm'); - - component.ngOnChanges({ editorContentString: {} as SimpleChange }); - - expect(updateSearchTermSpy).toHaveBeenCalledOnce(); - }); -}); diff --git a/src/test/javascript/spec/component/shared/sidebar/sidebar-accordion.component.spec.ts b/src/test/javascript/spec/component/shared/sidebar/sidebar-accordion.component.spec.ts index e1e67128974d..bcffb82bc9c1 100644 --- a/src/test/javascript/spec/component/shared/sidebar/sidebar-accordion.component.spec.ts +++ b/src/test/javascript/spec/component/shared/sidebar/sidebar-accordion.component.spec.ts @@ -63,10 +63,6 @@ describe('SidebarAccordionComponent', () => { jest.restoreAllMocks(); }); - it('should create', () => { - expect(component).toBeTruthy(); - }); - it('should toggle collapse state for a group', () => { const groupKey = 'noDate'; component.toggleGroupCategoryCollapse(groupKey); @@ -89,10 +85,20 @@ describe('SidebarAccordionComponent', () => { component.searchValue = 'test'; component.ngOnChanges(); + expect(component.expandAll).toHaveBeenCalledOnce(); + }); + + it('should call expandAll when filter is active', () => { + jest.spyOn(component, 'expandAll'); + + component.isFilterActive = true; + component.ngOnChanges(); + fixture.detectChanges(); - expect(component.expandAll).toHaveBeenCalled(); + expect(component.expandAll).toHaveBeenCalledOnce(); }); + it('should correctly call setStoredCollapseState when searchValue is cleared', () => { const expectedStateAfterClear = component.collapseState; component.searchValue = 'initial value'; @@ -106,7 +112,7 @@ describe('SidebarAccordionComponent', () => { fixture.detectChanges(); - expect(component.setStoredCollapseState).toHaveBeenCalled(); + expect(component.setStoredCollapseState).toHaveBeenCalledOnce(); expect(component.collapseState).toEqual(expectedStateAfterClear); }); diff --git a/src/test/javascript/spec/component/shared/sidebar/sidebar.component.spec.ts b/src/test/javascript/spec/component/shared/sidebar/sidebar.component.spec.ts index b809b3e3708d..55ad12ae7776 100644 --- a/src/test/javascript/spec/component/shared/sidebar/sidebar.component.spec.ts +++ b/src/test/javascript/spec/component/shared/sidebar/sidebar.component.spec.ts @@ -4,25 +4,39 @@ import { SidebarCardMediumComponent } from 'app/shared/sidebar/sidebar-card-medi import { SidebarCardItemComponent } from 'app/shared/sidebar/sidebar-card-item/sidebar-card-item.component'; import { SidebarCardDirective } from 'app/shared/sidebar/sidebar-card.directive'; import { ArtemisTestModule } from '../../../test.module'; -import { DebugElement } from '@angular/core'; import { SearchFilterPipe } from 'app/shared/pipes/search-filter.pipe'; import { SearchFilterComponent } from 'app/shared/search-filter/search-filter.component'; import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; - import { By } from '@angular/platform-browser'; -import { MockModule, MockPipe } from 'ng-mocks'; +import { MockComponent, MockDirective, MockModule, MockPipe, MockProvider } from 'ng-mocks'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MockRouterLinkDirective } from '../../../helpers/mocks/directive/mock-router-link.directive'; import { RouterModule } from '@angular/router'; +import { TranslateDirective } from 'app/shared/language/translate.directive'; +import { SidebarCardElement, SidebarData } from 'app/types/sidebar'; +import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; +import { ExerciseFilterModalComponent } from 'app/shared/exercise-filter/exercise-filter-modal.component'; +import { ExerciseFilterResults } from 'app/types/exercise-filter'; +import { EventEmitter } from '@angular/core'; +import { ExerciseCategory } from 'app/entities/exercise-category.model'; +import { ExerciseType } from 'app/entities/exercise.model'; +import { IconProp } from '@fortawesome/fontawesome-svg-core'; describe('SidebarComponent', () => { let component: SidebarComponent; let fixture: ComponentFixture; - let debugElement: DebugElement; + let modalService: NgbModal; beforeEach(() => { TestBed.configureTestingModule({ - imports: [ArtemisTestModule, MockModule(FormsModule), MockModule(ReactiveFormsModule), MockModule(RouterModule)], + imports: [ + ArtemisTestModule, + MockModule(FormsModule), + MockModule(ReactiveFormsModule), + MockModule(RouterModule), + MockDirective(TranslateDirective), + MockComponent(ExerciseFilterModalComponent), + ], declarations: [ SidebarComponent, SidebarCardMediumComponent, @@ -33,18 +47,19 @@ describe('SidebarComponent', () => { MockPipe(ArtemisTranslatePipe), MockRouterLinkDirective, ], + providers: [MockProvider(NgbModal)], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(SidebarComponent); component = fixture.componentInstance; - debugElement = fixture.debugElement; - fixture.detectChanges(); - }); + modalService = TestBed.inject(NgbModal); - it('should create', () => { - expect(component).toBeTruthy(); + component.sidebarData = { + sidebarType: 'default', + } as SidebarData; + fixture.detectChanges(); }); it('should filter sidebar items based on search criteria', () => { @@ -71,11 +86,16 @@ describe('SidebarComponent', () => { groupByCategory: true, ungroupedData: [], }; - fixture.detectChanges(); + component.sidebarDataBeforeFiltering = { + groupByCategory: true, + ungroupedData: [] as SidebarCardElement[], + }; + + const noDataMessageElement = fixture.debugElement.query(By.css('.scrollable-item-content')).nativeElement; - const noDataMessageElement = debugElement.query(By.css('[jhiTranslate$=noDataFound]')); expect(noDataMessageElement).toBeTruthy(); - expect(noDataMessageElement.nativeElement.getAttribute('jhiTranslate')).toBe('artemisApp.courseOverview.general.noDataFound'); + // unfortunately the translation key is cut off in debug mode that seems to be used for testing + expect(noDataMessageElement.getAttribute('ng-reflect-jhi-translate')).toBe('artemisApp.courseOverview.gene'); }); it('should give the correct size for exercises', () => { @@ -109,4 +129,118 @@ describe('SidebarComponent', () => { const size = component.getSize(); expect(size).toBe('M'); }); + + describe('openFilterExercisesLink', () => { + const FILTER_LINK_SELECTOR = '.text-primary a'; + + it('should display the filter link', () => { + component.showFilter = true; + fixture.detectChanges(); + + const filterLink = fixture.debugElement.query(By.css(FILTER_LINK_SELECTOR)); + + expect(filterLink).toBeTruthy(); + }); + + it('should NOT display the filter link when sidebarType is NOT exercise', () => { + const filterLink = fixture.debugElement.query(By.css(FILTER_LINK_SELECTOR)); + + expect(filterLink).toBeFalsy(); + }); + + it('should open modal on click with initialized filters', () => { + component.showFilter = true; + fixture.detectChanges(); + const filterAppliedMock = new EventEmitter(); + const mockReturnValue = { + result: Promise.resolve({}), + componentInstance: { + sidebarData: {}, + exerciseFilters: {}, + filterApplied: filterAppliedMock, + }, + } as NgbModalRef; + const openModalSpy = jest.spyOn(modalService, 'open').mockReturnValue(mockReturnValue); + const openFilterExercisesDialogSpy = jest.spyOn(component, 'openFilterExercisesDialog'); + const initFilterOptionsSpy = jest.spyOn(component, 'initializeFilterOptions'); + + const filterLink = fixture.debugElement.query(By.css(FILTER_LINK_SELECTOR)).nativeElement; + filterLink.click(); + + expect(initFilterOptionsSpy).toHaveBeenCalledOnce(); + expect(openFilterExercisesDialogSpy).toHaveBeenCalledOnce(); + expect(openModalSpy).toHaveBeenCalledWith(ExerciseFilterModalComponent, { animation: true, backdrop: 'static', size: 'lg' }); + }); + }); + + describe('openFilterExercisesDialog', () => { + it('should subscribe to filterApplied from modal', () => { + const filterAppliedEmitter = new EventEmitter(); + const mockModalRef: Partial = { + componentInstance: { + filterApplied: filterAppliedEmitter, + }, + }; + const openSpy = jest.spyOn(modalService, 'open').mockReturnValue(mockModalRef as NgbModalRef); + const subscribeSpy = jest.spyOn(filterAppliedEmitter, 'subscribe'); + + component.openFilterExercisesDialog(); + + expect(openSpy).toHaveBeenCalledOnce(); + expect(subscribeSpy).toHaveBeenCalledOnce(); + }); + + it('should update variables correctly when filterApplied is emitted', () => { + const filterAppliedEmitter = new EventEmitter(); + const mockModalRef: Partial = { + componentInstance: { + filterApplied: filterAppliedEmitter, + }, + }; + jest.spyOn(modalService, 'open').mockReturnValue(mockModalRef as NgbModalRef); + + const mockFilterResults: ExerciseFilterResults = { + filteredSidebarData: { + sidebarType: 'exercise', + groupByCategory: true, + ungroupedData: [{ title: 'test sidebar card element' } as SidebarCardElement], + groupedData: { + testGroup: { + entityData: [{ title: 'test group element' } as SidebarCardElement], + }, + }, + }, + appliedExerciseFilters: { + categoryFilter: { + isDisplayed: true, + options: [ + { + category: new ExerciseCategory('test', undefined), + searched: true, + }, + ], + }, + exerciseTypesFilter: { + isDisplayed: true, + options: [ + { + name: 'testType', + value: ExerciseType.PROGRAMMING, + checked: true, + icon: 'testIcon' as unknown as IconProp, + }, + ], + }, + }, + isFilterActive: true, + }; + + component.openFilterExercisesDialog(); + filterAppliedEmitter.emit(mockFilterResults); + + expect(component.sidebarData).toEqual(mockFilterResults.filteredSidebarData); + expect(component.exerciseFilters).toEqual(mockFilterResults.appliedExerciseFilters); + expect(component.isFilterActive).toBeTrue(); + }); + }); }); diff --git a/src/test/javascript/spec/component/shared/sidebar/sidebar.helper.spec.ts b/src/test/javascript/spec/component/shared/sidebar/sidebar.helper.spec.ts new file mode 100644 index 000000000000..a29596e12685 --- /dev/null +++ b/src/test/javascript/spec/component/shared/sidebar/sidebar.helper.spec.ts @@ -0,0 +1,329 @@ +import { + getAchievablePointsAndAchievedScoreFilterOptions, + getExerciseCategoryFilterOptions, + getExerciseDifficultyFilterOptions, + getExerciseTypeFilterOptions, +} from 'app/shared/sidebar/sidebar.helper'; +import { SidebarCardElement, SidebarData } from 'app/types/sidebar'; +import { ExerciseCategory } from 'app/entities/exercise-category.model'; +import { DifficultyLevel, Exercise, ExerciseType, getIcon } from 'app/entities/exercise.model'; +import { StudentParticipation } from 'app/entities/participation/student-participation.model'; +import { Result } from 'app/entities/result.model'; + +const EXERCISE_1 = { categories: [new ExerciseCategory('category1', '#691b0b'), new ExerciseCategory('category2', '#1b97ca')], maxPoints: 10 } as Exercise; +const EXERCISE_2 = { categories: [new ExerciseCategory('category3', '#0d3cc2'), new ExerciseCategory('category4', '#6ae8ac')], maxPoints: 5 } as Exercise; +const EXERCISE_3 = { categories: [new ExerciseCategory('category5', '#691b0b')], maxPoints: 2 } as Exercise; + +/** contains 1 duplicate categories and maxPoints with {@link EXERCISE_1} */ +const EXERCISE_4 = { categories: [new ExerciseCategory('category1', '#691b0b'), new ExerciseCategory('category8', '#1b97ca')], maxPoints: 10 } as Exercise; +const EXERCISE_5 = { categories: [] as ExerciseCategory[], maxPoints: 20 } as Exercise; + +const SIDEBAR_CARD_ELEMENT_1 = { + exercise: EXERCISE_1, + type: ExerciseType.TEXT, + difficulty: DifficultyLevel.HARD, + studentParticipation: { results: [{ score: 7.7 } as Result] } as StudentParticipation, +} as SidebarCardElement; +const SIDEBAR_CARD_ELEMENT_2 = { + exercise: EXERCISE_2, + type: ExerciseType.PROGRAMMING, + difficulty: DifficultyLevel.EASY, + studentParticipation: { results: [{ score: 82.3 } as Result] } as StudentParticipation, +} as SidebarCardElement; +const SIDEBAR_CARD_ELEMENT_3 = { + exercise: EXERCISE_3, + type: ExerciseType.QUIZ, + difficulty: DifficultyLevel.MEDIUM, + studentParticipation: { results: [{ score: 44.5 } as Result] } as StudentParticipation, +} as SidebarCardElement; +const SIDEBAR_CARD_ELEMENT_4 = { exercise: EXERCISE_4 } as SidebarCardElement; + +/** contains duplicated type and difficulty with {@link SIDEBAR_CARD_ELEMENT_2}*/ +const SIDEBAR_CARD_ELEMENT_5 = { exercise: EXERCISE_5, type: ExerciseType.PROGRAMMING, difficulty: DifficultyLevel.EASY } as SidebarCardElement; + +describe('getExerciseCategoryFilterOptions', () => { + it('should return all exercise categories', () => { + const sidebarData: SidebarData = { + groupByCategory: true, + groupedData: { + dueSoon: { + entityData: [], + }, + noDate: { + entityData: [SIDEBAR_CARD_ELEMENT_1, SIDEBAR_CARD_ELEMENT_2], + }, + past: { + entityData: [SIDEBAR_CARD_ELEMENT_3], + }, + }, + ungroupedData: [SIDEBAR_CARD_ELEMENT_1, SIDEBAR_CARD_ELEMENT_2, SIDEBAR_CARD_ELEMENT_3], + }; + + const exerciseCategories = getExerciseCategoryFilterOptions(sidebarData, undefined); + expect(exerciseCategories).toEqual({ + isDisplayed: true, + options: [ + { category: new ExerciseCategory('category1', '#691b0b'), searched: false }, + { category: new ExerciseCategory('category2', '#1b97ca'), searched: false }, + { category: new ExerciseCategory('category3', '#0d3cc2'), searched: false }, + { category: new ExerciseCategory('category4', '#6ae8ac'), searched: false }, + { category: new ExerciseCategory('category5', '#691b0b'), searched: false }, + ], + }); + }); + + it('should filter duplicates', () => { + const sidebarData: SidebarData = { + groupByCategory: true, + ungroupedData: [SIDEBAR_CARD_ELEMENT_1, SIDEBAR_CARD_ELEMENT_4], + }; + + const exerciseCategories = getExerciseCategoryFilterOptions(sidebarData, undefined); + expect(exerciseCategories).toEqual({ + isDisplayed: true, + options: [ + { category: new ExerciseCategory('category1', '#691b0b'), searched: false }, + { category: new ExerciseCategory('category2', '#1b97ca'), searched: false }, + { category: new ExerciseCategory('category8', '#1b97ca'), searched: false }, + ], + }); + }); + + it('should sort categories alphanumerical', () => { + const sidebarData: SidebarData = { + groupByCategory: true, + ungroupedData: [SIDEBAR_CARD_ELEMENT_2, SIDEBAR_CARD_ELEMENT_1], + }; + + const exerciseCategories = getExerciseCategoryFilterOptions(sidebarData, undefined); + expect(exerciseCategories).toEqual({ + isDisplayed: true, + options: [ + { category: new ExerciseCategory('category1', '#691b0b'), searched: false }, + { category: new ExerciseCategory('category2', '#1b97ca'), searched: false }, + { category: new ExerciseCategory('category3', '#0d3cc2'), searched: false }, + { category: new ExerciseCategory('category4', '#6ae8ac'), searched: false }, + ], + }); + }); + + it('should not be displayed if no categories are available', () => { + const sidebarData: SidebarData = { + groupByCategory: true, + ungroupedData: [SIDEBAR_CARD_ELEMENT_5], + }; + + const exerciseCategories = getExerciseCategoryFilterOptions(sidebarData, undefined); + expect(exerciseCategories).toEqual({ + isDisplayed: false, + options: [], + }); + }); + + it('should directly return if already initialized', () => { + const sidebarData: SidebarData = { + groupByCategory: true, + ungroupedData: [SIDEBAR_CARD_ELEMENT_1, SIDEBAR_CARD_ELEMENT_2], + }; + + const exerciseCategories = getExerciseCategoryFilterOptions(sidebarData, { + categoryFilter: { + isDisplayed: false, + options: [], + }, + }); + expect(exerciseCategories).toEqual({ + isDisplayed: false, + options: [], + }); + }); +}); + +describe('getExerciseTypeFilterOptions', () => { + it('should return present exercise types and sort them properly (same order as instructor creation)', () => { + const sidebarData: SidebarData = { + groupByCategory: true, + ungroupedData: [SIDEBAR_CARD_ELEMENT_1, SIDEBAR_CARD_ELEMENT_2, SIDEBAR_CARD_ELEMENT_3, SIDEBAR_CARD_ELEMENT_4, SIDEBAR_CARD_ELEMENT_5], + }; + + const exerciseTypesFilter = getExerciseTypeFilterOptions(sidebarData, undefined); + expect(exerciseTypesFilter).toEqual({ + isDisplayed: true, + options: [ + { name: 'artemisApp.courseStatistics.programming', value: ExerciseType.PROGRAMMING, checked: false, icon: getIcon(ExerciseType.PROGRAMMING) }, + { name: 'artemisApp.courseStatistics.quiz', value: ExerciseType.QUIZ, checked: false, icon: getIcon(ExerciseType.QUIZ) }, + { name: 'artemisApp.courseStatistics.text', value: ExerciseType.TEXT, checked: false, icon: getIcon(ExerciseType.TEXT) }, + ], + }); + }); + + it('should not contain duplicates', () => { + const sidebarData: SidebarData = { + groupByCategory: true, + ungroupedData: [SIDEBAR_CARD_ELEMENT_1, SIDEBAR_CARD_ELEMENT_2, SIDEBAR_CARD_ELEMENT_5], + }; + + const exerciseTypesFilter = getExerciseTypeFilterOptions(sidebarData, undefined); + expect(exerciseTypesFilter).toEqual({ + isDisplayed: true, + options: [ + { name: 'artemisApp.courseStatistics.programming', value: ExerciseType.PROGRAMMING, checked: false, icon: getIcon(ExerciseType.PROGRAMMING) }, + { name: 'artemisApp.courseStatistics.text', value: ExerciseType.TEXT, checked: false, icon: getIcon(ExerciseType.TEXT) }, + ], + }); + }); + + it('should directly return if already initialized', () => { + const sidebarData: SidebarData = { + groupByCategory: true, + ungroupedData: [SIDEBAR_CARD_ELEMENT_1, SIDEBAR_CARD_ELEMENT_2], + }; + + const exerciseTypesFilter = getExerciseTypeFilterOptions(sidebarData, { + exerciseTypesFilter: { + isDisplayed: false, + options: [], + }, + }); + expect(exerciseTypesFilter).toEqual({ + isDisplayed: false, + options: [], + }); + }); +}); + +describe('getExerciseDifficultyFilterOptions', () => { + it('should return present exercise difficulties and sort them ascending', () => { + const sidebarData: SidebarData = { + groupByCategory: true, + ungroupedData: [SIDEBAR_CARD_ELEMENT_1, SIDEBAR_CARD_ELEMENT_2, SIDEBAR_CARD_ELEMENT_3, SIDEBAR_CARD_ELEMENT_4, SIDEBAR_CARD_ELEMENT_5], + }; + + const difficultyFilter = getExerciseDifficultyFilterOptions(sidebarData, undefined); + expect(difficultyFilter).toEqual({ + isDisplayed: true, + options: [ + { name: 'artemisApp.exercise.easy', value: DifficultyLevel.EASY, checked: false }, + { name: 'artemisApp.exercise.medium', value: DifficultyLevel.MEDIUM, checked: false }, + { name: 'artemisApp.exercise.hard', value: DifficultyLevel.HARD, checked: false }, + ], + }); + }); + + it('should not contain duplicates', () => { + const sidebarData: SidebarData = { + groupByCategory: true, + ungroupedData: [SIDEBAR_CARD_ELEMENT_1, SIDEBAR_CARD_ELEMENT_2, SIDEBAR_CARD_ELEMENT_5], + }; + + const difficultyFilter = getExerciseDifficultyFilterOptions(sidebarData, undefined); + expect(difficultyFilter).toEqual({ + isDisplayed: true, + options: [ + { name: 'artemisApp.exercise.easy', value: DifficultyLevel.EASY, checked: false }, + { name: 'artemisApp.exercise.hard', value: DifficultyLevel.HARD, checked: false }, + ], + }); + }); + + it('should directly return if already initialized', () => { + const sidebarData: SidebarData = { + groupByCategory: true, + ungroupedData: [SIDEBAR_CARD_ELEMENT_1, SIDEBAR_CARD_ELEMENT_2], + }; + + const difficultyFilter = getExerciseDifficultyFilterOptions(sidebarData, { + difficultyFilter: { + isDisplayed: false, + options: [], + }, + }); + expect(difficultyFilter).toEqual({ + isDisplayed: false, + options: [], + }); + }); +}); + +describe('getAchievablePointsAndAchievedScoreFilterOptions', () => { + const expectedFilterForFirstThreePresentExercises = { + achievablePoints: { + isDisplayed: true, + filter: { + generalMin: 2, + generalMax: 10, + selectedMin: 2, + selectedMax: 10, + step: 1, + }, + }, + achievedScore: { + isDisplayed: true, + filter: { + generalMax: 85, + generalMin: 5, + selectedMax: 85, + selectedMin: 5, + step: 5, + }, + }, + }; + + it('should return present exercise point and score range', () => { + const sidebarData: SidebarData = { + groupByCategory: true, + ungroupedData: [SIDEBAR_CARD_ELEMENT_1, SIDEBAR_CARD_ELEMENT_2, SIDEBAR_CARD_ELEMENT_3], + }; + + const scoreAndPointsFilterOptions = getAchievablePointsAndAchievedScoreFilterOptions(sidebarData, undefined); + expect(scoreAndPointsFilterOptions).toEqual(expectedFilterForFirstThreePresentExercises); + }); + + it('should set scores filter to not displayed if no scores are present', () => { + const sidebarData: SidebarData = { + groupByCategory: true, + ungroupedData: [SIDEBAR_CARD_ELEMENT_4, SIDEBAR_CARD_ELEMENT_5], + }; + + const scoreAndPointsFilterOptions = getAchievablePointsAndAchievedScoreFilterOptions(sidebarData, undefined); + + expect(scoreAndPointsFilterOptions).toEqual({ + achievablePoints: { + isDisplayed: true, + filter: { + generalMax: 20, + generalMin: 10, + selectedMax: 20, + selectedMin: 10, + step: 1, + }, + }, + achievedScore: { + isDisplayed: false, + filter: { + generalMax: -Infinity, + generalMin: Infinity, + selectedMax: -Infinity, + selectedMin: Infinity, + step: 1, + }, + }, + }); + }); + + it('should directly return if already initialized and filters are not applied', () => { + const sidebarData: SidebarData = { + groupByCategory: true, + ungroupedData: [SIDEBAR_CARD_ELEMENT_1], + }; + + const scoreAndPointsFilterOptions = getAchievablePointsAndAchievedScoreFilterOptions(sidebarData, { + achievablePoints: expectedFilterForFirstThreePresentExercises.achievablePoints, + achievedScore: expectedFilterForFirstThreePresentExercises.achievedScore, + }); + expect(scoreAndPointsFilterOptions).toEqual({ + achievablePoints: expectedFilterForFirstThreePresentExercises.achievablePoints, + achievedScore: expectedFilterForFirstThreePresentExercises.achievedScore, + }); + }); +}); diff --git a/src/test/javascript/spec/component/statistics/statistics-average-score-graph.component.spec.ts b/src/test/javascript/spec/component/statistics/statistics-average-score-graph.component.spec.ts index e850210ee35e..2626dffc45e1 100644 --- a/src/test/javascript/spec/component/statistics/statistics-average-score-graph.component.spec.ts +++ b/src/test/javascript/spec/component/statistics/statistics-average-score-graph.component.spec.ts @@ -12,6 +12,7 @@ import { ArtemisNavigationUtilService } from 'app/utils/navigation.utils'; import { ChartExerciseTypeFilter } from 'app/shared/chart/chart-exercise-type-filter'; import { ChartCategoryFilter } from 'app/shared/chart/chart-category-filter'; import { TranslateDirective } from 'app/shared/language/translate.directive'; +import { ExerciseCategory } from 'app/entities/exercise-category.model'; describe('StatisticsAverageScoreGraphComponent', () => { let fixture: ComponentFixture; @@ -28,35 +29,35 @@ describe('StatisticsAverageScoreGraphComponent', () => { exerciseName: 'FacadePattern', averageScore: 0, exerciseType: ExerciseType.TEXT, - categories: [{ color: '#347aeb', category: 'structural pattern' }], + categories: [new ExerciseCategory('structural pattern', '#347aeb')], }; const exercise2 = { exerciseId: 2, exerciseName: 'BridgePattern', averageScore: 20, exerciseType: ExerciseType.PROGRAMMING, - categories: [{ color: '#347aeb', category: 'structural pattern' }], + categories: [new ExerciseCategory('structural pattern', '#347aeb')], }; const exercise3 = { exerciseId: 3, exerciseName: 'VisitorPattern', averageScore: 25, exerciseType: ExerciseType.FILE_UPLOAD, - categories: [{ color: '#c034eb', category: 'behavioral pattern' }], + categories: [new ExerciseCategory('behavioral pattern', '#c034eb')], }; const exercise4 = { exerciseId: 4, exerciseName: 'AdapterPattern', averageScore: 35, exerciseType: ExerciseType.QUIZ, - categories: [{ color: '#347aeb', category: 'structural pattern' }], + categories: [new ExerciseCategory('structural pattern', '#347aeb')], }; const exercise5 = { exerciseId: 5, exerciseName: 'ProxyPattern', averageScore: 40, exerciseType: ExerciseType.MODELING, - categories: [{ color: '#347aeb', category: 'structural pattern' }], + categories: [new ExerciseCategory('structural pattern', '#347aeb')], }; const exercise6 = { exerciseId: 6, exerciseName: 'BuilderPattern', averageScore: 50, exerciseType: ExerciseType.QUIZ }; const exercise7 = { exerciseId: 7, exerciseName: 'BehaviouralPattern', averageScore: 55, exerciseType: ExerciseType.PROGRAMMING }; diff --git a/src/test/javascript/spec/component/team/team-update-dialog.component.spec.ts b/src/test/javascript/spec/component/team/team-update-dialog.component.spec.ts index 7cc7027493d4..b3e76d5a92bb 100644 --- a/src/test/javascript/spec/component/team/team-update-dialog.component.spec.ts +++ b/src/test/javascript/spec/component/team/team-update-dialog.component.spec.ts @@ -1,4 +1,3 @@ -import * as ace from 'brace'; import { ComponentFixture, TestBed, discardPeriodicTasks, fakeAsync, flush, tick } from '@angular/core/testing'; import { DebugElement } from '@angular/core'; import { ArtemisTestModule } from '../../test.module'; @@ -20,8 +19,6 @@ import { TeamStudentSearchComponent } from 'app/exercises/shared/team/team-stude import { TranslateDirective } from 'app/shared/language/translate.directive'; describe('TeamUpdateDialogComponent', () => { - // needed to make sure ace is defined - ace.acequire('ace/ext/modelist.js'); let comp: TeamUpdateDialogComponent; let fixture: ComponentFixture; let debugElement: DebugElement; diff --git a/src/test/javascript/spec/component/team/teams.component.spec.ts b/src/test/javascript/spec/component/team/teams.component.spec.ts index 7a38a8298731..b5a874b28243 100644 --- a/src/test/javascript/spec/component/team/teams.component.spec.ts +++ b/src/test/javascript/spec/component/team/teams.component.spec.ts @@ -1,4 +1,3 @@ -import * as ace from 'brace'; import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; import { DebugElement } from '@angular/core'; import { ArtemisTestModule } from '../../test.module'; @@ -27,9 +26,6 @@ import { RouterTestingModule } from '@angular/router/testing'; import { teamRoute } from 'app/exercises/shared/team/team.route'; describe('TeamsComponent', () => { - // needed to make sure ace is defined - ace.acequire('ace/ext/modelist.js'); - let comp: TeamsComponent; let fixture: ComponentFixture; let debugElement: DebugElement; diff --git a/src/test/javascript/spec/component/text-editor/text-editor.component.spec.ts b/src/test/javascript/spec/component/text-editor/text-editor.component.spec.ts index 653989626b68..6c60b40ad95e 100644 --- a/src/test/javascript/spec/component/text-editor/text-editor.component.spec.ts +++ b/src/test/javascript/spec/component/text-editor/text-editor.component.spec.ts @@ -1,4 +1,3 @@ -import * as ace from 'brace'; import { DebugElement } from '@angular/core'; import dayjs from 'dayjs/esm'; import { ActivatedRoute, convertToParamMap } from '@angular/router'; @@ -44,9 +43,6 @@ import { ComplaintsStudentViewComponent } from 'app/complaints/complaints-for-st import { TranslateDirective } from 'app/shared/language/translate.directive'; describe('TextEditorComponent', () => { - // needed to make sure ace is defined - ace.acequire('ace/ext/modelist.js'); - let comp: TextEditorComponent; let fixture: ComponentFixture; let debugElement: DebugElement; diff --git a/src/test/javascript/spec/component/text-exercise/text-exercise-update.component.spec.ts b/src/test/javascript/spec/component/text-exercise/text-exercise-update.component.spec.ts index aa0f2c889832..88eff4e9d26f 100644 --- a/src/test/javascript/spec/component/text-exercise/text-exercise-update.component.spec.ts +++ b/src/test/javascript/spec/component/text-exercise/text-exercise-update.component.spec.ts @@ -23,6 +23,7 @@ import { NgModel } from '@angular/forms'; import { ExerciseTitleChannelNameComponent } from 'app/exercises/shared/exercise-title-channel-name/exercise-title-channel-name.component'; import { ExerciseUpdatePlagiarismComponent } from 'app/exercises/shared/plagiarism/exercise-update-plagiarism/exercise-update-plagiarism.component'; import { TeamConfigFormGroupComponent } from 'app/exercises/shared/team-config-form-group/team-config-form-group.component'; +import { ExerciseCategory } from 'app/entities/exercise-category.model'; describe('TextExercise Management Update Component', () => { let comp: TextExerciseUpdateComponent; @@ -363,7 +364,7 @@ describe('TextExercise Management Update Component', () => { it('should updateCategories properly by making category available for selection again when removing it', () => { comp.textExercise = new TextExercise(undefined, undefined); comp.exerciseCategories = []; - const newCategories = [{ category: 'Easy' }, { category: 'Hard' }]; + const newCategories = [new ExerciseCategory('Easy', undefined), new ExerciseCategory('Hard', undefined)]; comp.updateCategories(newCategories); diff --git a/src/test/javascript/spec/component/tutorial-groups/course-tutorial-groups/course-tutorial-groups.component.spec.ts b/src/test/javascript/spec/component/tutorial-groups/course-tutorial-groups/course-tutorial-groups.component.spec.ts index 7a722cf69505..cf60468091e9 100644 --- a/src/test/javascript/spec/component/tutorial-groups/course-tutorial-groups/course-tutorial-groups.component.spec.ts +++ b/src/test/javascript/spec/component/tutorial-groups/course-tutorial-groups/course-tutorial-groups.component.spec.ts @@ -2,7 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { TutorialGroup } from 'app/entities/tutorial-group/tutorial-group.model'; import { TutorialGroupsService } from 'app/course/tutorial-groups/services/tutorial-groups.service'; import { MockRouter } from '../../../helpers/mocks/mock-router'; -import { MockModule, MockPipe, MockProvider } from 'ng-mocks'; +import { MockDirective, MockModule, MockPipe, MockProvider } from 'ng-mocks'; import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; import { AlertService } from 'app/core/util/alert.service'; import { ActivatedRoute, Router, RouterModule, convertToParamMap } from '@angular/router'; @@ -18,6 +18,7 @@ import { SidebarComponent } from 'app/shared/sidebar/sidebar.component'; import { SearchFilterPipe } from 'app/shared/pipes/search-filter.pipe'; import { SearchFilterComponent } from 'app/shared/search-filter/search-filter.component'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { TranslateDirective } from 'app/shared/language/translate.directive'; describe('CourseTutorialGroupsComponent', () => { let fixture: ComponentFixture; @@ -32,7 +33,7 @@ describe('CourseTutorialGroupsComponent', () => { router.navigate.mockImplementation(() => Promise.resolve(true)); TestBed.configureTestingModule({ - imports: [ArtemisTestModule, RouterModule, MockModule(FormsModule), MockModule(ReactiveFormsModule)], + imports: [ArtemisTestModule, RouterModule, MockModule(FormsModule), MockModule(ReactiveFormsModule), MockDirective(TranslateDirective)], declarations: [CourseTutorialGroupsComponent, MockPipe(ArtemisTranslatePipe), SidebarComponent, SearchFilterComponent, MockPipe(SearchFilterPipe)], providers: [ MockProvider(TutorialGroupsService), diff --git a/src/test/javascript/spec/entities/exercise-category.model.spec.ts b/src/test/javascript/spec/entities/exercise-category.model.spec.ts new file mode 100644 index 000000000000..2beba4b62f40 --- /dev/null +++ b/src/test/javascript/spec/entities/exercise-category.model.spec.ts @@ -0,0 +1,49 @@ +import { ExerciseCategory } from 'app/entities/exercise-category.model'; + +describe('ExerciseCategory', () => { + describe('equals', () => { + it('should return true if the two exercise categories are equal', () => { + const exerciseCategory1 = new ExerciseCategory('Category 1', 'red'); + const exerciseCategory2 = new ExerciseCategory('Category 1', 'red'); + + expect(exerciseCategory1.equals(exerciseCategory2)).toBeTruthy(); + }); + + it('should return false if the two exercise categories are not equal', () => { + const exerciseCategory1 = new ExerciseCategory('Category 1', 'red'); + const exerciseCategory2 = new ExerciseCategory('Category 2', 'blue'); + + expect(exerciseCategory1.equals(exerciseCategory2)).toBeFalsy(); + }); + }); + + describe('compare', () => { + it("should return 0 if the two exercise categories' display text is the same", () => { + const exerciseCategory1 = new ExerciseCategory('Category 1', 'red'); + const exerciseCategory2 = new ExerciseCategory('Category 1', 'blue'); + + expect(exerciseCategory1.compare(exerciseCategory2)).toBe(0); + }); + + it("should return -1 if the first exercise category's display text is smaller than the second exercise category's display text", () => { + const exerciseCategory1 = new ExerciseCategory('Category 1', 'red'); + const exerciseCategory2 = new ExerciseCategory('Category 2', 'blue'); + + expect(exerciseCategory1.compare(exerciseCategory2)).toBe(-1); + }); + + it("should return 1 if the first exercise category's display text is larger than the second exercise category's display text", () => { + const exerciseCategory1 = new ExerciseCategory('Category 2', 'red'); + const exerciseCategory2 = new ExerciseCategory('Category 1', 'blue'); + + expect(exerciseCategory1.compare(exerciseCategory2)).toBe(1); + }); + + it('should return -1 if the first exercise category is undefined', () => { + const exerciseCategory1 = new ExerciseCategory(undefined, 'red'); + const exerciseCategory2 = new ExerciseCategory('Category 1', 'blue'); + + expect(exerciseCategory1.compare(exerciseCategory2)).toBe(-1); + }); + }); +}); diff --git a/src/test/javascript/spec/service/account.service.spec.ts b/src/test/javascript/spec/service/account.service.spec.ts index caf5c105276b..03bcd9a04a48 100644 --- a/src/test/javascript/spec/service/account.service.spec.ts +++ b/src/test/javascript/spec/service/account.service.spec.ts @@ -14,7 +14,6 @@ import { Course } from 'app/entities/course.model'; import { Exercise } from 'app/entities/exercise.model'; import { Participation } from 'app/entities/participation/participation.model'; import { Team } from 'app/entities/team.model'; -import { MockProfileService } from '../helpers/mocks/service/mock-profile.service'; describe('AccountService', () => { let accountService: AccountService; @@ -53,7 +52,6 @@ describe('AccountService', () => { httpService, new MockWebsocketService(), new MockFeatureToggleService(), - new MockProfileService(), ); getStub = jest.spyOn(httpService, 'get'); postStub = jest.spyOn(httpService, 'post'); @@ -538,17 +536,7 @@ describe('AccountService', () => { fetchStub = jest.spyOn(accountService, 'fetch'); }); - it('should retrieve user if vcs token is missing', () => { - accountService['versionControlAccessTokenRequired'] = true; - user.vcsAccessToken = undefined; - accountService.userIdentity = user; - - accountService.identity(); - expect(fetchStub).toHaveBeenCalledOnce(); - }); - it('should not retrieve user if vcs token is missing but not required', () => { - accountService['versionControlAccessTokenRequired'] = false; user.vcsAccessToken = undefined; accountService.userIdentity = user; @@ -557,7 +545,6 @@ describe('AccountService', () => { }); it('should not retrieve user if vcs token is present', () => { - accountService['versionControlAccessTokenRequired'] = true; user.vcsAccessToken = 'iAmAToken'; accountService.userIdentity = user; diff --git a/src/test/javascript/spec/service/guided-tour.service.spec.ts b/src/test/javascript/spec/service/guided-tour.service.spec.ts index 04694f2919c5..fb7e9a1b9b0d 100644 --- a/src/test/javascript/spec/service/guided-tour.service.spec.ts +++ b/src/test/javascript/spec/service/guided-tour.service.spec.ts @@ -591,8 +591,8 @@ describe('GuidedTourService', () => { guidedTourService.enableUserInteraction(htmlTarget, userInteractionEvent); expect(querySelectorSpy).not.toHaveBeenCalled(); })); - it('should enableUserInteraction with UserInteractionEvent.ACE_EDITOR', fakeAsync(() => { - const userInteractionEvent = UserInteractionEvent.ACE_EDITOR; + it('should enableUserInteraction with UserInteractionEvent.MONACO_EDITOR', fakeAsync(() => { + const userInteractionEvent = UserInteractionEvent.MONACO_EDITOR; observeMutationsStub.mockReturnValue(of({ addedNodes: { length: 0 } as NodeList, removedNodes: { length: 0 } as NodeList } as MutationRecord)); guidedTourService.enableUserInteraction(htmlTarget, userInteractionEvent); expect(querySelectorSpy).toHaveBeenCalledOnce(); diff --git a/src/test/javascript/spec/service/modeling-exercise.service.spec.ts b/src/test/javascript/spec/service/modeling-exercise.service.spec.ts index e3227f1b718f..5fdfe0efe181 100644 --- a/src/test/javascript/spec/service/modeling-exercise.service.spec.ts +++ b/src/test/javascript/spec/service/modeling-exercise.service.spec.ts @@ -22,8 +22,8 @@ describe('ModelingExercise Service', () => { let httpMock: HttpTestingController; let elemDefault: ModelingExercise; let plagiarismResult: ModelingPlagiarismResult; - const category = { color: 'red', category: 'testCategory' } as ExerciseCategory; - const categories = [JSON.stringify(category) as ExerciseCategory]; + const category = new ExerciseCategory('testCategory', 'red'); + const categories = [JSON.stringify(category) as unknown as ExerciseCategory] as ExerciseCategory[]; beforeEach(() => { TestBed.configureTestingModule({ imports: [HttpClientTestingModule], diff --git a/src/test/javascript/spec/util/shared/utils.spec.ts b/src/test/javascript/spec/util/shared/utils.spec.ts index 296e7ba1fabc..b20604e27615 100644 --- a/src/test/javascript/spec/util/shared/utils.spec.ts +++ b/src/test/javascript/spec/util/shared/utils.spec.ts @@ -4,6 +4,7 @@ import { isExamExercise, round, roundScorePercentSpecifiedByCourseSettings, + roundToNextMultiple, roundValueSpecifiedByCourseSettings, stringifyIgnoringFields, } from 'app/shared/util/utils'; @@ -119,3 +120,25 @@ describe('isExamExercise', () => { expect(isExamExerciseResult).toBeFalse(); }); }); + +describe('roundUpToNextMultiple', () => { + it('should round up to multiple of 5 if value is closer to lower multiple', () => { + expect(roundToNextMultiple(21, 5, true)).toBe(25); + }); + + it('should round up to multiple of 5 if value is right underneath next multiple', () => { + expect(roundToNextMultiple(24.8, 5, true)).toBe(25); + }); + + it('should round down to multiple of 5 if value is over next multiple', () => { + expect(roundToNextMultiple(24.8, 5, false)).toBe(20); + }); + + it('should return value is it is a multiple', () => { + expect(roundToNextMultiple(25, 5, true)).toBe(25); + }); + + it('should round up to multiple of 1', () => { + expect(roundToNextMultiple(8.2, 1, true)).toBe(9); + }); +}); diff --git a/src/test/playwright/support/pageobjects/assessment/ProgrammingExerciseAssessmentPage.ts b/src/test/playwright/support/pageobjects/assessment/ProgrammingExerciseAssessmentPage.ts index 1e8725207c5d..e2085f6b5bc4 100644 --- a/src/test/playwright/support/pageobjects/assessment/ProgrammingExerciseAssessmentPage.ts +++ b/src/test/playwright/support/pageobjects/assessment/ProgrammingExerciseAssessmentPage.ts @@ -6,7 +6,7 @@ import { AbstractExerciseAssessmentPage } from './AbstractExerciseAssessmentPage */ export class ProgrammingExerciseAssessmentPage extends AbstractExerciseAssessmentPage { async provideFeedbackOnCodeLine(lineIndex: number, points: number, feedback: string) { - // We can't change elements from the ace editor, so we can't use custom ids here + // We can't change elements from the Monaco editor, so we can't use custom ids here await this.page.locator('.view-line').nth(lineIndex).hover(); await this.page.locator('.monaco-add-feedback-button').click(); await this.typeIntoFeedbackEditor(feedback, lineIndex); diff --git a/src/test/resources/config/application.yml b/src/test/resources/config/application.yml index bba3939e92f3..ba0acb609f26 100644 --- a/src/test/resources/config/application.yml +++ b/src/test/resources/config/application.yml @@ -66,6 +66,8 @@ artemis: default: "~~invalid~~" ocaml: default: "~~invalid~~" + rust: + default: "~~invalid~~" spring: application: diff --git a/supporting_scripts/analysis-of-endpoint-connections/src/main/java/de/tum/cit/endpointanalysis/EndpointAnalyzer.java b/supporting_scripts/analysis-of-endpoint-connections/src/main/java/de/tum/cit/endpointanalysis/EndpointAnalyzer.java index 250966bfd432..b096ba1201c6 100644 --- a/supporting_scripts/analysis-of-endpoint-connections/src/main/java/de/tum/cit/endpointanalysis/EndpointAnalyzer.java +++ b/supporting_scripts/analysis-of-endpoint-connections/src/main/java/de/tum/cit/endpointanalysis/EndpointAnalyzer.java @@ -15,9 +15,9 @@ public class EndpointAnalyzer { - private static String EndpointAnalysisResultPath = "endpointAnalysisResult.json"; + private static String ENDPOINT_ANALYSIS_RESULT_PATH = "endpointAnalysisResult.json"; - private static final Logger logger = LoggerFactory.getLogger(EndpointAnalyzer.class); + private static final Logger log = LoggerFactory.getLogger(EndpointAnalyzer.class); public static void main(String[] args) { analyzeEndpoints(); @@ -60,10 +60,13 @@ private static void analyzeEndpoints() { for (EndpointInformation endpoint : endpointClass.endpoints()) { String endpointURI = endpoint.buildComparableEndpointUri(); - List matchingRestCalls = restCallMap.getOrDefault(endpointURI, new ArrayList<>()); + List restCallsWithMatchingURI = restCallMap.getOrDefault(endpointURI, new ArrayList<>()); // Check for wildcard endpoints if no exact match is found - checkForWildcardEndpoints(endpoint, matchingRestCalls, endpointURI, restCallMap); + checkForWildcardEndpoints(endpoint, restCallsWithMatchingURI, endpointURI, restCallMap); + + List matchingRestCalls = restCallsWithMatchingURI.stream() + .filter(restCall -> restCall.method().toLowerCase().equals(endpoint.getHttpMethod().toLowerCase())).toList(); if (matchingRestCalls.isEmpty()) { unusedEndpoints.add(endpoint); @@ -75,10 +78,10 @@ private static void analyzeEndpoints() { } EndpointAnalysis endpointAnalysis = new EndpointAnalysis(endpointsAndMatchingRestCalls, unusedEndpoints); - mapper.writeValue(new File(EndpointAnalysisResultPath), endpointAnalysis); + mapper.writeValue(new File(ENDPOINT_ANALYSIS_RESULT_PATH), endpointAnalysis); } catch (IOException e) { - logger.error("Failed to analyze endpoints", e); + log.error("Failed to analyze endpoints", e); } } @@ -119,26 +122,26 @@ private static void printEndpointAnalysisResult() { ObjectMapper mapper = new ObjectMapper(); EndpointAnalysis endpointsAndMatchingRestCalls = null; try { - endpointsAndMatchingRestCalls = mapper.readValue(new File(EndpointAnalysisResultPath), new TypeReference() { + endpointsAndMatchingRestCalls = mapper.readValue(new File(ENDPOINT_ANALYSIS_RESULT_PATH), new TypeReference() { }); } catch (IOException e) { - logger.error("Failed to deserialize endpoint analysis result", e); + log.error("Failed to deserialize endpoint analysis result", e); return; } endpointsAndMatchingRestCalls.unusedEndpoints().stream().forEach(endpoint -> { - logger.info("============================================="); - logger.info("Endpoint URI: {}", endpoint.buildCompleteEndpointURI()); - logger.info("HTTP method: {}", endpoint.httpMethodAnnotation()); - logger.info("File path: {}", endpoint.className()); - logger.info("Line: {}", endpoint.line()); - logger.info("============================================="); - logger.info("No matching REST call found for endpoint: {}", endpoint.buildCompleteEndpointURI()); - logger.info("---------------------------------------------"); - logger.info(""); + log.info("============================================="); + log.info("Endpoint URI: {}", endpoint.buildCompleteEndpointURI()); + log.info("HTTP method: {}", endpoint.httpMethodAnnotation()); + log.info("File path: {}", endpoint.className()); + log.info("Line: {}", endpoint.line()); + log.info("============================================="); + log.info("No matching REST call found for endpoint: {}", endpoint.buildCompleteEndpointURI()); + log.info("---------------------------------------------"); + log.info(""); }); - logger.info("Number of endpoints without matching REST calls: {}", endpointsAndMatchingRestCalls.unusedEndpoints().size()); + log.info("Number of endpoints without matching REST calls: {}", endpointsAndMatchingRestCalls.unusedEndpoints().size()); } } diff --git a/supporting_scripts/analysis-of-endpoint-connections/src/main/java/de/tum/cit/endpointanalysis/EndpointInformation.java b/supporting_scripts/analysis-of-endpoint-connections/src/main/java/de/tum/cit/endpointanalysis/EndpointInformation.java index 19b32dc8881b..52f33cef81e2 100644 --- a/supporting_scripts/analysis-of-endpoint-connections/src/main/java/de/tum/cit/endpointanalysis/EndpointInformation.java +++ b/supporting_scripts/analysis-of-endpoint-connections/src/main/java/de/tum/cit/endpointanalysis/EndpointInformation.java @@ -4,16 +4,16 @@ import com.fasterxml.jackson.annotation.JsonIgnore; -public record EndpointInformation(String requestMapping, String endpoint, String httpMethodAnnotation, String URI, String className, int line, List otherAnnotations) { +public record EndpointInformation(String requestMapping, String endpoint, String httpMethodAnnotation, String uri, String className, int line, List otherAnnotations) { - public String buildCompleteEndpointURI() { + String buildCompleteEndpointURI() { StringBuilder result = new StringBuilder(); if (this.requestMapping != null && !this.requestMapping.isEmpty()) { // Remove quotes from the requestMapping as they are used to define the String in the source code but are not part of the URI result.append(this.requestMapping.replace("\"", "")); } // Remove quotes from the URI as they are used to define the String in the source code but are not part of the URI - result.append(this.URI.replace("\"", "")); + result.append(this.uri.replace("\"", "")); return result.toString(); } @@ -23,7 +23,7 @@ String buildComparableEndpointUri() { } @JsonIgnore - public String getHttpMethod() { + String getHttpMethod() { return switch (this.httpMethodAnnotation) { case "GetMapping" -> "get"; case "PostMapping" -> "post"; diff --git a/supporting_scripts/analysis-of-endpoint-connections/src/main/java/de/tum/cit/endpointanalysis/EndpointParser.java b/supporting_scripts/analysis-of-endpoint-connections/src/main/java/de/tum/cit/endpointanalysis/EndpointParser.java index b0ab2cdb1f0b..9fb5a05b72da 100644 --- a/supporting_scripts/analysis-of-endpoint-connections/src/main/java/de/tum/cit/endpointanalysis/EndpointParser.java +++ b/supporting_scripts/analysis-of-endpoint-connections/src/main/java/de/tum/cit/endpointanalysis/EndpointParser.java @@ -36,7 +36,7 @@ public class EndpointParser { static final String REST_CALL_PARSING_RESULT_PATH = "restCalls.json"; - private static final Logger logger = LoggerFactory.getLogger(EndpointParser.class); + private static final Logger log = LoggerFactory.getLogger(EndpointParser.class); public static void main(String[] args) { final Path absoluteDirectoryPath = Path.of("../../src/main/java").toAbsolutePath().normalize(); @@ -48,7 +48,7 @@ public static void main(String[] args) { filesToParse = paths.filter(Files::isRegularFile).filter(path -> path.toString().endsWith(".java")).map(Path::toString).toArray(String[]::new); } catch (IOException e) { - logger.error("Error reading files from directory: {}", absoluteDirectoryPath, e); + log.error("Error reading files from directory: {}", absoluteDirectoryPath, e); } parseServerEndpoints(filesToParse); @@ -190,9 +190,9 @@ else if (annotation instanceof NormalAnnotationExpr normalAnnotationExpr) { */ private static void printFilesFailedToParse(List filesFailedToParse) { if (!filesFailedToParse.isEmpty()) { - logger.warn("Files failed to parse:", filesFailedToParse); + log.warn("Files failed to parse:", filesFailedToParse); for (String file : filesFailedToParse) { - logger.warn(file); + log.warn(file); } } } @@ -211,7 +211,7 @@ private static void writeEndpointsToFile(List endpoint new ObjectMapper().writeValue(new File(ENDPOINT_PARSING_RESULT_PATH), endpointClasses); } catch (IOException e) { - logger.error("Failed to write endpoint information to file", e); + log.error("Failed to write endpoint information to file", e); } } } diff --git a/supporting_scripts/analysis-of-endpoint-connections/src/main/java/de/tum/cit/endpointanalysis/RestCallAnalyzer.java b/supporting_scripts/analysis-of-endpoint-connections/src/main/java/de/tum/cit/endpointanalysis/RestCallAnalyzer.java index aac71d6573bf..19f8cbc6c56f 100644 --- a/supporting_scripts/analysis-of-endpoint-connections/src/main/java/de/tum/cit/endpointanalysis/RestCallAnalyzer.java +++ b/supporting_scripts/analysis-of-endpoint-connections/src/main/java/de/tum/cit/endpointanalysis/RestCallAnalyzer.java @@ -17,7 +17,7 @@ public class RestCallAnalyzer { private static final String REST_CALL_ANALYSIS_RESULT_PATH = "restCallAnalysisResult.json"; - private static final Logger logger = LoggerFactory.getLogger(RestCallAnalyzer.class); + private static final Logger log = LoggerFactory.getLogger(RestCallAnalyzer.class); public static void main(String[] args) { analyzeRestCalls(); @@ -58,16 +58,19 @@ private static void analyzeRestCalls() { for (RestCallFileInformation restCallFile : restCalls) { for (RestCallInformation restCall : restCallFile.restCalls()) { String restCallURI = restCall.buildComparableRestCallUri(); - List matchingEndpoints = endpointMap.getOrDefault(restCallURI, new ArrayList<>()); + List endpointsWithMatchingUri = endpointMap.getOrDefault(restCallURI, new ArrayList<>()); - checkForWildcardMatches(restCall, matchingEndpoints, restCallURI, endpointMap); + checkForWildcardMatches(restCall, endpointsWithMatchingUri, restCallURI, endpointMap); - if (matchingEndpoints.isEmpty()) { + List endpointsWithMatchingHttpMethod = endpointsWithMatchingUri.stream() + .filter(endpoint -> endpoint.getHttpMethod().toLowerCase().equals(restCall.method().toLowerCase())).toList(); + + if (endpointsWithMatchingHttpMethod.isEmpty()) { restCallsWithoutMatchingEndpoint.add(restCall); } else { - for (EndpointInformation endpoint : matchingEndpoints) { - restCallsWithMatchingEndpoint.add(new RestCallWithMatchingEndpoint(endpoint, restCall, restCall.fileName())); + for (EndpointInformation endpoint : endpointsWithMatchingHttpMethod) { + restCallsWithMatchingEndpoint.add(new RestCallWithMatchingEndpoint(endpoint, restCall, restCall.filePath())); } } } @@ -77,7 +80,7 @@ private static void analyzeRestCalls() { mapper.writeValue(new File(REST_CALL_ANALYSIS_RESULT_PATH), restCallAnalysis); } catch (IOException e) { - logger.error("Failed to analyze REST calls", e); + log.error("Failed to analyze REST calls", e); } } @@ -124,21 +127,22 @@ private static void printRestCallAnalysisResult() { }); } catch (IOException e) { - logger.error("Failed to deserialize rest call analysis results", e); + log.error("Failed to deserialize rest call analysis results", e); + return; } restCallsAndMatchingEndpoints.restCallsWithoutMatchingEndpoints().stream().forEach(endpoint -> { - logger.info("============================================="); - logger.info("REST call URI: {}", endpoint.buildCompleteRestCallURI()); - logger.info("HTTP method: {}", endpoint.method()); - logger.info("File path: {}", endpoint.fileName()); - logger.info("Line: {}", endpoint.line()); - logger.info("============================================="); - logger.info("No matching endpoint found for REST call: {}", endpoint.buildCompleteRestCallURI()); - logger.info("---------------------------------------------"); - logger.info(""); + log.info("============================================="); + log.info("REST call URI: {}", endpoint.buildCompleteRestCallURI()); + log.info("HTTP method: {}", endpoint.method()); + log.info("File path: {}", endpoint.filePath()); + log.info("Line: {}", endpoint.line()); + log.info("============================================="); + log.info("No matching endpoint found for REST call: {}", endpoint.buildCompleteRestCallURI()); + log.info("---------------------------------------------"); + log.info(""); }); - logger.info("Number of REST calls without matching endpoints: {}", restCallsAndMatchingEndpoints.restCallsWithoutMatchingEndpoints().size()); + log.info("Number of REST calls without matching endpoints: {}", restCallsAndMatchingEndpoints.restCallsWithoutMatchingEndpoints().size()); } } diff --git a/supporting_scripts/analysis-of-endpoint-connections/src/main/java/de/tum/cit/endpointanalysis/RestCallFileInformation.java b/supporting_scripts/analysis-of-endpoint-connections/src/main/java/de/tum/cit/endpointanalysis/RestCallFileInformation.java index 847ec03b1561..2582f57443cc 100644 --- a/supporting_scripts/analysis-of-endpoint-connections/src/main/java/de/tum/cit/endpointanalysis/RestCallFileInformation.java +++ b/supporting_scripts/analysis-of-endpoint-connections/src/main/java/de/tum/cit/endpointanalysis/RestCallFileInformation.java @@ -1,4 +1,6 @@ package de.tum.cit.endpointanalysis; -public record RestCallFileInformation(String fileName, RestCallInformation[] restCalls) { +import java.util.List; + +public record RestCallFileInformation(String filePath, List restCalls) { } diff --git a/supporting_scripts/analysis-of-endpoint-connections/src/main/java/de/tum/cit/endpointanalysis/RestCallInformation.java b/supporting_scripts/analysis-of-endpoint-connections/src/main/java/de/tum/cit/endpointanalysis/RestCallInformation.java index fb1e44f92f2a..b6567d707a47 100644 --- a/supporting_scripts/analysis-of-endpoint-connections/src/main/java/de/tum/cit/endpointanalysis/RestCallInformation.java +++ b/supporting_scripts/analysis-of-endpoint-connections/src/main/java/de/tum/cit/endpointanalysis/RestCallInformation.java @@ -1,18 +1,23 @@ package de.tum.cit.endpointanalysis; -public record RestCallInformation(String method, String url, int line, String fileName) { +public record RestCallInformation(String method, String url, String filePath, int line) { - public String buildCompleteRestCallURI() { + String buildCompleteRestCallURI() { return this.url.replace("`", ""); } - public String buildComparableRestCallUri() { + String buildComparableRestCallUri() { // Replace arguments with placeholder String result = this.buildCompleteRestCallURI().replaceAll("\\$\\{.*?\\}", ":param:"); // Remove query parameters result = result.split("\\?")[0]; + // Some URIs in the artemis client start with a redundant `/`. To be able to compare them to the endpoint URIs, we remove it. + if (result.startsWith("/")) { + result = result.substring(1); + } + return result; } } diff --git a/supporting_scripts/analysis-of-endpoint-connections/src/main/typeScript/Postprocessor.ts b/supporting_scripts/analysis-of-endpoint-connections/src/main/typeScript/Postprocessor.ts index 18b54a5f0ac4..46b95a821a18 100644 --- a/supporting_scripts/analysis-of-endpoint-connections/src/main/typeScript/Postprocessor.ts +++ b/supporting_scripts/analysis-of-endpoint-connections/src/main/typeScript/Postprocessor.ts @@ -5,7 +5,7 @@ interface RestCall { method: string; url: string; line: number; - fileName: string; + filePath: string; } enum ParsingResultType { @@ -38,17 +38,17 @@ class ParsingResult { } export class Postprocessor { - static filesWithRestCalls: { fileName: string, restCalls: RestCall[] }[] = []; + static filesWithRestCalls: { filePath: string, restCalls: RestCall[] }[] = []; private readonly restCalls: RestCall[] = []; - private readonly fileName: string; + private readonly filePath: string; private readonly ast: TSESTree.Program; /** - * @param fileName - The name of the file being processed. + * @param filePath - The path of the file being processed. * @param ast - The abstract syntax tree (AST) of the processed file. */ - constructor(fileName: string, ast: TSESTree.Program) { - this.fileName = fileName; + constructor(filePath: string, ast: TSESTree.Program) { + this.filePath = filePath; this.ast = ast; } @@ -61,7 +61,7 @@ export class Postprocessor { } }); if (this.restCalls.length > 0) { - Postprocessor.filesWithRestCalls.push( {fileName: this.fileName, restCalls: this.restCalls} ); + Postprocessor.filesWithRestCalls.push({ filePath: this.filePath, restCalls: this.restCalls }); } } @@ -108,10 +108,10 @@ export class Postprocessor { urlEvaluationResult = this.evaluateUrl(node.arguments[0], methodDefinition, node, classBody); } - const fileName = this.fileName; + const filePath = this.filePath; if (urlEvaluationResult.resultType === ParsingResultType.EVALUATE_URL_SUCCESS) { - for (let url of urlEvaluationResult.result) { - this.restCalls.push({ method, url, line, fileName }); + for (const url of urlEvaluationResult.result) { + this.restCalls.push({ method, url, line, filePath }); } } } @@ -175,6 +175,7 @@ export class Postprocessor { } /** + * Evaluates a template literal AST node to determine its URL value. * Evaluates a template literal AST node to determine its URL value. * * This method evaluates the provided template literal node by calling `evaluateTemplateLiteralExpression`. @@ -541,7 +542,7 @@ export class Postprocessor { simpleTraverse(methodDefinition, { enter: (node) => { if (node.type === 'VariableDeclaration') { - for (let decl of node.declarations) { + for (const decl of node.declarations) { if (decl.id.type === 'Identifier' && decl.id.name === name && decl.init) { const tempResult = this.evaluateUrl(decl.init, methodDefinition, restCall, classBody); if (tempResult.resultType === ParsingResultType.EVALUATE_URL_SUCCESS) { @@ -587,7 +588,7 @@ export class Postprocessor { * @returns An array of AST nodes representing the parameters of the constructor. */ getConstructorArgumentsFromClassBody(classBody: TSESTree.ClassBody): TSESTree.Parameter[] { - for (let node of classBody.body) { + for (const node of classBody.body) { if (node.type === 'MethodDefinition' && node.key.type === 'Identifier' && node.key.name === 'constructor') { return node.value.params; } @@ -615,11 +616,11 @@ export class Postprocessor { const superClass = Preprocessor.PREPROCESSING_RESULTS.get(this.getClassNameFromClassBody(classBody)); if (superClass) { const constructorArguments = this.getConstructorArgumentsFromClassBody(classBody.body); - for (let superConstructorCallArguments of superClass.superConstructorCalls) { + for (const superConstructorCallArguments of superClass.superConstructorCalls) { for (let i = 0; i < superConstructorCallArguments.arguments.length; i++) { - let constructorArgument = constructorArguments[i]; + const constructorArgument = constructorArguments[i]; if (superConstructorCallArguments.arguments[i] !== '' && constructorArgument.type === 'TSParameterProperty' - && constructorArgument.parameter.type === 'Identifier' && constructorArgument.parameter.name === memberExprKey) { + && constructorArgument.parameter.type === 'Identifier' && constructorArgument.parameter.name === memberExprKey) { memberExpressionResult.push(superConstructorCallArguments.arguments[i]); resultType = ParsingResultType.EVALUATE_MEMBER_EXPRESSION_SUCCESS; }