diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 000000000..62f1dbaa3 --- /dev/null +++ b/.codecov.yml @@ -0,0 +1,15 @@ +# We'd like to place this file to the .config/ folder as well, +# however Codecov doesn't support it yet (or is buggy, see +# https://github.com/codecov/codecov-action/issues/1465) + +# Ignore test files themselves in the Codecov report +# see: https://about.codecov.io/blog/should-i-include-test-files-in-code-coverage-calculations/ +ignore: + - "spec/**/*" + - "**/*_spec.rb" + +# https://docs.codecov.com/docs/commit-status +coverage: + status: + project: off + patch: off diff --git a/.config/.codecov.yml b/.config/.codecov.yml deleted file mode 100644 index 9e14ade12..000000000 --- a/.config/.codecov.yml +++ /dev/null @@ -1,5 +0,0 @@ -# Ignore test files themselves in the Codecov report -# see: https://about.codecov.io/blog/should-i-include-test-files-in-code-coverage-calculations/ -ignore: - - "spec/" - - "*_spec.rb" diff --git a/.config/.cypress.js b/.config/.cypress.js new file mode 100644 index 000000000..5d8e10485 --- /dev/null +++ b/.config/.cypress.js @@ -0,0 +1,7 @@ +module.exports = { + e2e: { + // Base URL is set via Docker environment variable + viewportHeight: 1000, + viewportWidth: 1400, + }, +}; diff --git a/.config/commands/docker.justfile b/.config/commands/docker.justfile new file mode 100644 index 000000000..15872ed4b --- /dev/null +++ b/.config/commands/docker.justfile @@ -0,0 +1,49 @@ +# Prints this help message +[private] +help: + @just --list --justfile {{source_file()}} + +# Starts the dev docker containers +@up *args: + #!/usr/bin/env bash + cd {{justfile_directory()}}/docker/development/ + docker compose up {{args}} + +# Starts the dev docker containers and preseeds the database +[confirm("This will reset all your data in the database locally. Continue? (y/n)")] +up-reseed *args: + #!/usr/bin/env bash + cd {{justfile_directory()}}/docker/development/ + export DB_SQL_PRESEED_URL="https://github.com/MaMpf-HD/mampf-init-data/raw/main/data/20220923120841_mampf.sql" + export UPLOADS_PRESEED_URL="https://github.com/MaMpf-HD/mampf-init-data/raw/main/data/uploads.zip" + docker compose rm --stop --force mampf && docker compose up {{args}} + +# Removes the development docker containers +@down: + #!/usr/bin/env bash + cd {{justfile_directory()}}/docker/development/ + docker compose down + +# Stops the development docker containers (without removing them) +@stop: + #!/usr/bin/env bash + cd {{justfile_directory()}}/docker/development/ + docker compose stop + +# Puts you into a shell of your desired *development* docker container +@shell name="mampf" shell="bash": + #!/usr/bin/env bash + cd {{justfile_directory()}}/docker/development/ + docker compose exec -it {{name}} bash + +# Puts you into a shell of your desired *test* docker container +@shell-test name="mampf" shell="bash": + #!/usr/bin/env bash + cd {{justfile_directory()}}/docker/test/ + docker compose exec -it {{name}} {{shell}} + +# Puts you into the rails console of the dev docker mampf container +@rails-c: + #!/usr/bin/env bash + cd {{justfile_directory()}}/docker/development/ + docker compose exec mampf bundle exec rails c diff --git a/.config/commands/test.justfile b/.config/commands/test.justfile new file mode 100644 index 000000000..c2cacf556 --- /dev/null +++ b/.config/commands/test.justfile @@ -0,0 +1,14 @@ +# Prints this help message +[private] +help: + @just --list --justfile {{source_file()}} + +# Starts the interactive Cypress test runner UI +cypress: + #!/usr/bin/env bash + cd {{justfile_directory()}}/docker/test + docker compose -f docker-compose.yml -f cypress.yml -f cypress-interactive.yml up --exit-code-from cypress + +# Opens Codecov in the default browser +codecov: + xdg-open https://app.codecov.io/gh/MaMpf-HD/mampf diff --git a/.config/eslint.mjs b/.config/eslint.mjs index 008e58374..0cf5ab6de 100644 --- a/.config/eslint.mjs +++ b/.config/eslint.mjs @@ -11,6 +11,7 @@ import js from "@eslint/js"; import stylistic from "@stylistic/eslint-plugin"; import erb from "eslint-plugin-erb"; +import pluginCypress from "eslint-plugin-cypress/flat"; import globals from "globals"; const ignoreFilesWithSprocketRequireSyntax = [ @@ -22,6 +23,17 @@ const ignoreFilesWithSprocketRequireSyntax = [ "vendor/assets/javascripts/thredded_timeago.js", ]; +const ignoreCypressArchivedTests = [ + "spec/cypress/e2e/admin_spec.cy.archive.js", + "spec/cypress/e2e/courses_spec.cy.archive.js", + "spec/cypress/e2e/media_spec.cy.archive.js", + "spec/cypress/e2e/search_spec.cy.archive.js", + "spec/cypress/e2e/submissions_spec.cy.archive.js", + "spec/cypress/e2e/thredded_spec.cy.archive.js", + "spec/cypress/e2e/tutorials_spec.cy.archive.js", + "spec/cypress/e2e/watchlists_spec.cy.archive.js", +]; + const customGlobals = { TomSelect: "readable", bootstrap: "readable", @@ -87,16 +99,15 @@ const customGlobals = { // KaTeX renderMathInElement: "readable", -}; -// We don't have cypress linting yet, as the Cypress ESLint plugin -// doesn't support the new flat config yet -// https://github.com/cypress-io/eslint-plugin-cypress/issues/146 + openAnnotationIfSpecifiedInUrl: "readable", +}; export default [ js.configs.recommended, // Allow linting of ERB files, see https://github.com/Splines/eslint-plugin-erb erb.configs.recommended, + pluginCypress.configs.recommended, // Globally ignore the following paths { ignores: [ @@ -107,8 +118,8 @@ export default [ "public/packs-test/", "public/uploads/", "public/pdfcomprezzor/", - "spec/cypress/", ...ignoreFilesWithSprocketRequireSyntax, + ...ignoreCypressArchivedTests, ], }, { diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index db47d5027..f6ad48da5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -59,4 +59,39 @@ jobs: files: ./coverage/coverage.xml token: ${{ secrets.CODECOV_TOKEN }} verbose: true - codecov_yml_path: ./config/codecov.yml + + # Cypress end-to-end tests + e2e-tests: + name: e2e (Cypress) + environment: testing + runs-on: ubuntu-latest + permissions: write-all + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v3 + + # see https://github.com/orgs/MaMpf-HD/packages?repo_name=mampf + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build docker images + # As the docker-compose.yml file uses contexts like "./../..", we have + # to change the working directory here. + working-directory: docker/test + run: > + docker buildx bake -f ./docker-compose.yml -f ./cypress.yml + -f ./../../.github/workflows/docker-compose-cache.json + + - name: Run Cypress tests + working-directory: docker/test + # "cypress run" is defined in the cypress.yml entrypoint + run: docker compose -f ./docker-compose.yml -f ./cypress.yml run cypress diff --git a/.justfile b/.justfile new file mode 100644 index 000000000..5b36d3496 --- /dev/null +++ b/.justfile @@ -0,0 +1,36 @@ +# Documentation: https://just.systems/man/en/ + +# Prints this help message +[private] +help: + @just --list + +# Test-related commands +mod test ".config/commands/test.justfile" +# see https://github.com/casey/just/issues/2216 +# alias t := test + +# Docker-related commands +mod docker ".config/commands/docker.justfile" + +# Opens the MaMpf wiki in the default browser +wiki: + #!/usr/bin/env bash + xdg-open https://github.com/MaMpf-HD/mampf/wiki + +# Opens the MaMpf pull requests (PRs) in the default browser +prs: + #!/usr/bin/env bash + xdg-open https://github.com/MaMpf-HD/mampf/pulls + +# Opens the PR for the current branch in the default browser +pr: + #!/usr/bin/env bash + branchname=$(git branch --show-current) + xdg-open "https://github.com/MaMpf-HD/mampf/pulls?q=is%3Apr+is%3Aopen+head%3A$branchname" + +# Opens the MaMpf GitHub code tree at the current branch in the default browser +code branch="": + #!/usr/bin/env bash + branchname={{ if branch == "" {"$(git branch --show-current)"} else {branch} }} + xdg-open https://github.com/MaMpf-HD/mampf/tree/$branchname diff --git a/.vscode/extensions.json b/.vscode/extensions.json index baa13b8bb..d98e845e1 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -3,6 +3,7 @@ "shopify.ruby-lsp", "dbaeumer.vscode-eslint", "streetsidesoftware.code-spell-checker", - "streetsidesoftware.code-spell-checker-german" + "streetsidesoftware.code-spell-checker-german", + "nefrob.vscode-just-syntax" ] } \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 9dcca743d..bb386a29a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -13,7 +13,6 @@ "source.fixAll.eslint": "explicit" }, "eslint.format.enable": true, // use ESLint as formatter - "eslint.experimental.useFlatConfig": true, // this disables VSCode built-in formatter (instead we want to use ESLint) "javascript.validate.enable": false, "eslint.options": { @@ -71,7 +70,8 @@ "node_modules/": true, "pdfcomprezzor/": true, "coverage/": true, - "solr/": true + "solr/": true, + ".docker/": true }, "files.associations": { "*.js.erb": "javascript", @@ -104,8 +104,9 @@ ////////////////////////////////////// "cSpell.words": [ "commontator", + "factorybot", "helpdesk", + "katex", "turbolinks" - ], - "rubyLsp.customRubyCommand": "set -o allexport && . ./docker-dummy.env && set +o allexport" + ] } \ No newline at end of file diff --git a/Gemfile b/Gemfile index 81419a97c..88a53f4e6 100644 --- a/Gemfile +++ b/Gemfile @@ -136,7 +136,6 @@ group :test, :development, :docker_development do gem "factory_bot_rails" gem "rspec-rails" - gem "cypress-on-rails", "~> 1.0" gem "simplecov-cobertura" gem "rspec-github" diff --git a/Gemfile.lock b/Gemfile.lock index 87e30b105..9cd99b59f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,21 +1,21 @@ GIT remote: https://github.com/rails/sprockets-rails - revision: 065cbe83989c44019eca7161782ed4fdb6473517 + revision: 2c04236faaacd021b7810289cbac93e962ff14da branch: master specs: - sprockets-rails (3.4.2) - actionpack (>= 5.2) - activesupport (>= 5.2) + sprockets-rails (3.5.2) + actionpack (>= 6.1) + activesupport (>= 6.1) sprockets (>= 3.0.0) GIT remote: https://github.com/sunspot/sunspot.git - revision: 41a311e9eff34df5ae7c51905574677dd474e91e + revision: 2cb3e49c6e9c8ec23b8d95f9dcf2d28d1248d61b glob: sunspot_rails/*.gemspec specs: - sunspot_rails (2.6.0) + sunspot_rails (2.7.1) rails (>= 5) - sunspot (= 2.6.0) + sunspot (= 2.7.1) GIT remote: https://github.com/thredded/thredded-markdown_katex.git @@ -55,48 +55,48 @@ GIT GIT remote: https://github.com/zdennis/activerecord-import.git - revision: f41a5782b0a3f19cf42e88063aa4adbefafe4092 + revision: fca8b823ae695b03714837cc6603f51525c60505 branch: master specs: - activerecord-import (1.6.0) + activerecord-import (1.7.0) activerecord (>= 4.2) GEM remote: https://rubygems.org/ specs: - Ascii85 (1.1.0) - RubyInline (3.14.0) + Ascii85 (1.1.1) + RubyInline (3.14.1) ZenTest (~> 4.3) - ZenTest (4.12.1) - actioncable (7.1.3.2) - actionpack (= 7.1.3.2) - activesupport (= 7.1.3.2) + ZenTest (4.12.2) + actioncable (7.1.3.4) + actionpack (= 7.1.3.4) + activesupport (= 7.1.3.4) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (7.1.3.2) - actionpack (= 7.1.3.2) - activejob (= 7.1.3.2) - activerecord (= 7.1.3.2) - activestorage (= 7.1.3.2) - activesupport (= 7.1.3.2) + actionmailbox (7.1.3.4) + actionpack (= 7.1.3.4) + activejob (= 7.1.3.4) + activerecord (= 7.1.3.4) + activestorage (= 7.1.3.4) + activesupport (= 7.1.3.4) mail (>= 2.7.1) net-imap net-pop net-smtp - actionmailer (7.1.3.2) - actionpack (= 7.1.3.2) - actionview (= 7.1.3.2) - activejob (= 7.1.3.2) - activesupport (= 7.1.3.2) + actionmailer (7.1.3.4) + actionpack (= 7.1.3.4) + actionview (= 7.1.3.4) + activejob (= 7.1.3.4) + activesupport (= 7.1.3.4) mail (~> 2.5, >= 2.5.4) net-imap net-pop net-smtp rails-dom-testing (~> 2.2) - actionpack (7.1.3.2) - actionview (= 7.1.3.2) - activesupport (= 7.1.3.2) + actionpack (7.1.3.4) + actionview (= 7.1.3.4) + activesupport (= 7.1.3.4) nokogiri (>= 1.8.5) racc rack (>= 2.2.4) @@ -104,15 +104,15 @@ GEM rack-test (>= 0.6.3) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - actiontext (7.1.3.2) - actionpack (= 7.1.3.2) - activerecord (= 7.1.3.2) - activestorage (= 7.1.3.2) - activesupport (= 7.1.3.2) + actiontext (7.1.3.4) + actionpack (= 7.1.3.4) + activerecord (= 7.1.3.4) + activestorage (= 7.1.3.4) + activesupport (= 7.1.3.4) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.1.3.2) - activesupport (= 7.1.3.2) + actionview (7.1.3.4) + activesupport (= 7.1.3.4) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) @@ -124,24 +124,24 @@ GEM jsonapi-renderer (>= 0.1.1.beta1, < 0.3) active_record_union (1.3.0) activerecord (>= 4.0) - activejob (7.1.3.2) - activesupport (= 7.1.3.2) + activejob (7.1.3.4) + activesupport (= 7.1.3.4) globalid (>= 0.3.6) - activemodel (7.1.3.2) - activesupport (= 7.1.3.2) - activerecord (7.1.3.2) - activemodel (= 7.1.3.2) - activesupport (= 7.1.3.2) + activemodel (7.1.3.4) + activesupport (= 7.1.3.4) + activerecord (7.1.3.4) + activemodel (= 7.1.3.4) + activesupport (= 7.1.3.4) timeout (>= 0.4.0) activerecord-nulldb-adapter (1.0.1) activerecord (>= 5.2.0, < 7.2) - activestorage (7.1.3.2) - actionpack (= 7.1.3.2) - activejob (= 7.1.3.2) - activerecord (= 7.1.3.2) - activesupport (= 7.1.3.2) + activestorage (7.1.3.4) + actionpack (= 7.1.3.4) + activejob (= 7.1.3.4) + activerecord (= 7.1.3.4) + activesupport (= 7.1.3.4) marcel (~> 1.0) - activesupport (7.1.3.2) + activesupport (7.1.3.4) base64 bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) @@ -151,16 +151,17 @@ GEM minitest (>= 5.1) mutex_m tzinfo (~> 2.0) - acts_as_list (1.1.0) - activerecord (>= 4.2) + acts_as_list (1.2.2) + activerecord (>= 6.1) + activesupport (>= 6.1) acts_as_tree (2.9.1) activerecord (>= 3.0.0) acts_as_votable (0.14.0) - addressable (2.8.6) - public_suffix (>= 2.0.2, < 6.0) + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) afm (0.2.2) ast (2.4.2) - autoprefixer-rails (10.4.16.0) + autoprefixer-rails (10.4.19.0) execjs (~> 2) babel-source (5.8.35) babel-transpiler (0.7.0) @@ -169,9 +170,9 @@ GEM barby (0.6.9) base64 (0.2.0) bcrypt (3.1.20) - bigdecimal (3.1.7) + bigdecimal (3.1.8) bindex (0.8.1) - bootsnap (1.18.3) + bootsnap (1.18.4) msgpack (~> 1.2) bootstrap (5.3.3) autoprefixer-rails (>= 9.1.0) @@ -179,12 +180,13 @@ GEM bootstrap_form (5.4.0) actionpack (>= 6.1) activemodel (>= 6.1) - builder (3.2.4) + builder (3.3.0) byebug (11.1.3) - cancancan (3.5.0) + cancancan (3.6.1) case_transform (0.2) activesupport - childprocess (5.0.0) + childprocess (5.1.0) + logger (~> 1.5) choice (0.2.0) chunky_png (1.4.0) clipboard-rails (1.7.1) @@ -199,7 +201,7 @@ GEM rails (>= 6.0) sprockets-rails will_paginate - concurrent-ruby (1.2.3) + concurrent-ruby (1.3.4) connection_pool (2.4.1) content_disposition (1.0.0) coveralls (0.7.1) @@ -211,10 +213,8 @@ GEM crass (1.0.6) css_parser (1.17.1) addressable - cypress-on-rails (1.17.0) - rack dalli (3.2.8) - database_cleaner-active_record (2.1.0) + database_cleaner-active_record (2.2.0) activerecord (>= 5.a) database_cleaner-core (~> 2.0.0) database_cleaner-core (2.0.1) @@ -229,12 +229,12 @@ GEM warden (~> 1.2.3) devise-bootstrap-views (1.1.0) diff-lcs (1.5.1) - docile (1.4.0) + docile (1.4.1) domain_name (0.6.20240107) down (5.4.2) addressable (~> 2.8) drb (2.2.1) - erubi (1.12.0) + erubi (1.13.0) erubis (2.7.0) et-orbi (1.2.11) tzinfo @@ -248,7 +248,7 @@ GEM factory_bot_rails (6.4.3) factory_bot (~> 6.4) railties (>= 5.0.0) - faker (3.3.1) + faker (3.4.2) i18n (>= 1.8.11, < 2) faraday (1.10.3) faraday-em_http (~> 1.0) @@ -268,13 +268,13 @@ GEM faraday-httpclient (1.0.1) faraday-multipart (1.0.4) multipart-post (~> 2) - faraday-net_http (1.0.1) + faraday-net_http (1.0.2) faraday-net_http_persistent (1.2.0) faraday-patron (1.0.0) faraday-rack (1.0.0) faraday-retry (1.0.3) fastimage (2.3.1) - ffi (1.16.3) + ffi (1.17.0-x86_64-linux-gnu) filesize (0.2.0) friendly_id (5.5.1) activerecord (>= 4.0.0) @@ -286,25 +286,26 @@ GEM globalid (1.2.1) activesupport (>= 6.1) hashery (2.1.2) - highline (2.1.0) + highline (3.1.0) + reline html-pipeline (2.14.3) activesupport (>= 2) nokogiri (>= 1.4) htmlentities (4.3.4) http-accept (1.7.0) - http-cookie (1.0.5) + http-cookie (1.0.6) domain_name (~> 0.5) - i18n (1.14.4) + i18n (1.14.5) concurrent-ruby (~> 1.0) - image_processing (1.12.2) + image_processing (1.13.0) mini_magick (>= 4.9.5, < 5) ruby-vips (>= 2.0.17, < 3) inline_svg (1.9.0) activesupport (>= 3.0) nokogiri (>= 1.6) io-console (0.7.2) - irb (1.12.0) - rdoc + irb (1.14.0) + rdoc (>= 4.0.0) reline (>= 0.4.2) jbuilder (2.12.0) actionview (>= 5.0.0) @@ -345,12 +346,13 @@ GEM kramdown-parser-gfm (1.1.0) kramdown (~> 2.0) language_server-protocol (3.17.0.3) - launchy (3.0.0) + launchy (3.0.1) addressable (~> 2.8) childprocess (~> 5.0) listen (3.9.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) + logger (1.6.0) loofah (2.22.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) @@ -362,19 +364,19 @@ GEM marcel (1.0.4) mime-types (3.5.2) mime-types-data (~> 3.2015) - mime-types-data (3.2024.0305) - mini_magick (4.12.0) + mime-types-data (3.2024.0806) + mini_magick (4.13.2) mini_mime (1.1.5) - minitest (5.22.3) + minitest (5.24.1) mobility (1.2.9) i18n (>= 0.6.10, < 2) request_store (~> 1.0) msgpack (1.7.2) multi_json (1.15.0) - multipart-post (2.4.0) + multipart-post (2.4.1) mustache (1.1.1) mutex_m (0.2.0) - net-imap (0.4.10) + net-imap (0.4.14) date net-protocol net-pop (0.1.2) @@ -384,8 +386,8 @@ GEM net-smtp (0.5.0) net-protocol netrc (0.11.0) - nio4r (2.7.1) - nokogiri (1.16.4-x86_64-linux) + nio4r (2.7.3) + nokogiri (1.16.7-x86_64-linux) racc (~> 1.4) onebox (2.2.19) addressable (~> 2.8.0) @@ -397,8 +399,8 @@ GEM options (2.3.2) orm_adapter (0.5.0) pairing_heap (3.1.0) - parallel (1.24.0) - parser (3.3.1.0) + parallel (1.26.2) + parser (3.3.4.2) ast (~> 2.4.1) racc pdf-reader (2.12.0) @@ -407,7 +409,7 @@ GEM hashery (~> 2.0) ruby-rc4 ttfunk - pg (1.5.6) + pg (1.5.7) pgreset (0.4) popper_js (2.11.8) pr_geohash (1.0.0) @@ -419,20 +421,20 @@ GEM actionmailer (>= 3) net-smtp premailer (~> 1.7, >= 1.7.9) - progress_bar (1.3.3) - highline (>= 1.6, < 3) + progress_bar (1.3.4) + highline (>= 1.6) options (~> 2.3.0) - prometheus_exporter (2.1.0) + prometheus_exporter (2.1.1) webrick psych (5.1.2) stringio - public_suffix (5.0.5) + public_suffix (6.0.1) puma (6.4.2) nio4r (~> 2.0) pundit (2.3.2) activesupport (>= 3.0.0) raabro (1.4.0) - racc (1.7.3) + racc (1.8.1) rack (2.2.9) rack-proxy (0.7.7) rack @@ -443,20 +445,20 @@ GEM rackup (1.0.0) rack (< 3) webrick - rails (7.1.3.2) - actioncable (= 7.1.3.2) - actionmailbox (= 7.1.3.2) - actionmailer (= 7.1.3.2) - actionpack (= 7.1.3.2) - actiontext (= 7.1.3.2) - actionview (= 7.1.3.2) - activejob (= 7.1.3.2) - activemodel (= 7.1.3.2) - activerecord (= 7.1.3.2) - activestorage (= 7.1.3.2) - activesupport (= 7.1.3.2) + rails (7.1.3.4) + actioncable (= 7.1.3.4) + actionmailbox (= 7.1.3.4) + actionmailer (= 7.1.3.4) + actionpack (= 7.1.3.4) + actiontext (= 7.1.3.4) + actionview (= 7.1.3.4) + activejob (= 7.1.3.4) + activemodel (= 7.1.3.4) + activerecord (= 7.1.3.4) + activestorage (= 7.1.3.4) + activesupport (= 7.1.3.4) bundler (>= 1.15.0) - railties (= 7.1.3.2) + railties (= 7.1.3.4) rails-dom-testing (2.2.0) activesupport (>= 5.0.0) minitest @@ -474,9 +476,9 @@ GEM railties (>= 6.0.0, < 8) rails_gravatar (1.0.4) actionview - railties (7.1.3.2) - actionpack (= 7.1.3.2) - activesupport (= 7.1.3.2) + railties (7.1.3.4) + actionpack (= 7.1.3.4) + activesupport (= 7.1.3.4) irb rackup (>= 1.0.0) rake (>= 12.2) @@ -485,16 +487,16 @@ GEM rainbow (3.1.1) rake (13.2.1) rb-fsevent (0.11.2) - rb-inotify (0.10.1) + rb-inotify (0.11.1) ffi (~> 1.0) - rdoc (6.6.3.1) + rdoc (6.7.0) psych (>= 4.0.0) - redis-client (0.22.1) + redis-client (0.22.2) connection_pool - regexp_parser (2.9.0) - reline (0.5.4) + regexp_parser (2.9.2) + reline (0.5.9) io-console (~> 0.5) - request_store (1.6.0) + request_store (1.7.0) rack (>= 1.4) responders (3.1.1) actionpack (>= 5.2) @@ -504,7 +506,8 @@ GEM http-cookie (>= 1.0.2, < 2.0) mime-types (>= 1.16, < 4.0) netrc (~> 0.8) - rexml (3.2.6) + rexml (3.3.5) + strscan rgl (0.6.6) pairing_heap (>= 0.3, < 4.0) rexml (~> 3.2, >= 3.2.4) @@ -519,15 +522,15 @@ GEM faraday (>= 0.9, < 3, != 2.0.0) rspec-core (3.13.0) rspec-support (~> 3.13.0) - rspec-expectations (3.13.0) + rspec-expectations (3.13.1) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-github (2.4.0) rspec-core (~> 3.0) - rspec-mocks (3.13.0) + rspec-mocks (3.13.1) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-rails (6.1.2) + rspec-rails (6.1.3) actionpack (>= 6.1) activesupport (>= 6.1) railties (>= 6.1) @@ -536,23 +539,23 @@ GEM rspec-mocks (~> 3.13) rspec-support (~> 3.13) rspec-support (3.13.1) - rubocop (1.63.4) + rubocop (1.65.1) json (~> 2.3) language_server-protocol (>= 3.17.0) parallel (~> 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 1.8, < 3.0) + regexp_parser (>= 2.4, < 3.0) rexml (>= 3.2.5, < 4.0) rubocop-ast (>= 1.31.1, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.31.3) + rubocop-ast (1.32.0) parser (>= 3.3.1.0) - rubocop-performance (1.21.0) + rubocop-performance (1.21.1) rubocop (>= 1.48.1, < 2.0) rubocop-ast (>= 1.31.1, < 2.0) - rubocop-rails (2.24.1) + rubocop-rails (2.25.1) activesupport (>= 4.2.0) rack (>= 1.1) rubocop (>= 1.33.0, < 2.0) @@ -561,11 +564,12 @@ GEM rexml ruby-progressbar (1.13.0) ruby-rc4 (0.1.5) - ruby-vips (2.2.1) + ruby-vips (2.2.2) ffi (~> 1.12) + logger ruby2_keywords (0.0.5) rubyzip (2.3.2) - sanitize (6.1.0) + sanitize (6.1.2) crass (~> 1.0.2) nokogiri (>= 1.12.0) sass-rails (6.0.0) @@ -586,11 +590,12 @@ GEM shrine (3.6.0) content_disposition (~> 1.0) down (~> 5.1) - sidekiq (7.2.4) + sidekiq (7.3.0) concurrent-ruby (< 2) connection_pool (>= 2.3.0) + logger rack (>= 2.2.4) - redis-client (>= 0.19.0) + redis-client (>= 0.22.2) sidekiq-cron (1.12.0) fugit (~> 1.8) globalid (>= 1.0.1) @@ -619,18 +624,20 @@ GEM stream (0.5.5) streamio-ffmpeg (3.0.2) multi_json (~> 1.8) - stringio (3.1.0) - sunspot (2.6.0) + stringio (3.1.1) + strscan (3.1.0) + sunspot (2.7.1) + bigdecimal pr_geohash (~> 1.0) rsolr (>= 1.1.1, < 3) - sunspot_solr (2.6.0) + sunspot_solr (2.7.1) sync (0.5.0) - term-ansicolor (1.8.0) + term-ansicolor (1.11.2) tins (~> 1.0) - terser (1.2.2) + terser (1.2.3) execjs (>= 0.3.0, < 3) thor (1.3.1) - tilt (2.3.0) + tilt (2.4.0) timeago_js (3.0.2.2) timeout (0.4.1) tins (1.33.0) @@ -663,12 +670,12 @@ GEM railties (>= 5.2) semantic_range (>= 2.3.0) webrick (1.8.1) - websocket (1.2.10) + websocket (1.2.11) websocket-driver (0.7.6) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) - will_paginate (4.0.0) - zeitwerk (2.6.13) + will_paginate (4.0.1) + zeitwerk (2.6.17) PLATFORMS x86_64-linux @@ -690,7 +697,6 @@ DEPENDENCIES coffee-rails (~> 5.0.0) commontator coveralls - cypress-on-rails (~> 1.0) dalli (>= 2.7) database_cleaner-active_record devise diff --git a/TESTING.md b/TESTING.md index e45526234..4179a8019 100644 --- a/TESTING.md +++ b/TESTING.md @@ -18,7 +18,7 @@ Furthermore, you can setup special scenarios by providing a file in `spec/cypres that can be called by `cy.appScenario("setup")` for example. Always try to create as much as you can in the scenario and then test the interaction! -For more information visit [cypress-documentation](https://docs.cypress.io) and the used gem [cypress-on-rails](https://github.com/shakacode/cypress-on-rails) +For more information visit [cypress-documentation](https://docs.cypress.io). # Testing rspec diff --git a/app/abilities/annotation_ability.rb b/app/abilities/annotation_ability.rb index 1d95ba944..00d6e2b6c 100644 --- a/app/abilities/annotation_ability.rb +++ b/app/abilities/annotation_ability.rb @@ -6,6 +6,7 @@ def initialize(user) annotation.user == user end - can [:new, :create, :update_annotations, :num_nearby_posted_mistake_annotations], Annotation + can [:index, :new, :create, :update_annotations, :num_nearby_posted_mistake_annotations], + Annotation end end diff --git a/app/assets/javascripts/annotations_overview.js b/app/assets/javascripts/annotations_overview.js new file mode 100644 index 000000000..6d46a7617 --- /dev/null +++ b/app/assets/javascripts/annotations_overview.js @@ -0,0 +1,13 @@ +function colorAnnotationCardsSharedByStudents() { + const annotationCards = $("[data-annotation-card-category]"); + for (let card of annotationCards) { + const category = card.dataset.annotationCardCategory; + if (!category) { + continue; + } + const color = Category.getByName(category).color; + card.style.borderColor = color; + } +} + +colorAnnotationCardsSharedByStudents(); diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 88e205048..b6faa2615 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -98,6 +98,7 @@ //= require thyme/metadata_manager //= require thyme/resizer //= require thyme/utility +//= require thyme/annotations/url_annotation_opener //= require thyme/thyme_player //= require thyme/thyme_editor //= require thyme/thyme_feedback diff --git a/app/assets/javascripts/cards.js b/app/assets/javascripts/cards.js new file mode 100644 index 000000000..7c2cb6a9b --- /dev/null +++ b/app/assets/javascripts/cards.js @@ -0,0 +1,7 @@ +$(document).on("turbolinks:load", function () { + $(".mampf-card").on("mousemove", function (event) { + const { x, y } = this.getBoundingClientRect(); + this.style.setProperty("--x", event.clientX - x); + this.style.setProperty("--y", event.clientY - y); + }); +}); diff --git a/app/assets/javascripts/thyme/annotations/annotation_manager.js b/app/assets/javascripts/thyme/annotations/annotation_manager.js index aed7d7c3e..163d19836 100644 --- a/app/assets/javascripts/thyme/annotations/annotation_manager.js +++ b/app/assets/javascripts/thyme/annotations/annotation_manager.js @@ -41,7 +41,7 @@ class AnnotationManager { } /* - Updates the markers on the timeline, i.e. the visual represention of the annotations. + Updates the markers on the timeline, i.e. the visual representation of the annotations. This method is e.g. used for rearranging the markers when the window is being resized. Don't mix up with updateAnnotatons() which sends an AJAX request and checks for changes in the database. @@ -82,7 +82,7 @@ class AnnotationManager { This method is e.g. used when a new annotation is being created. Don't mix up with updateMarkers() which just updates the position of the markers! - onSucess = A function that is triggered when the annotations have been + onSuccess = A function that is triggered when the annotations have been successfully updated. onSuccess = A function that is triggered when the annotations have been successfully updated. diff --git a/app/assets/javascripts/thyme/annotations/url_annotation_opener.js b/app/assets/javascripts/thyme/annotations/url_annotation_opener.js new file mode 100644 index 000000000..fbc60f003 --- /dev/null +++ b/app/assets/javascripts/thyme/annotations/url_annotation_opener.js @@ -0,0 +1,9 @@ +// eslint-disable-next-line no-unused-vars +function openAnnotationIfSpecifiedInUrl() { + const annotationValue = new URLSearchParams(window.location.search).get("ann"); + if (!annotationValue) { + return; + } + const annotationId = Number(annotationValue); + thymeAttributes.annotationArea.showAnnotationWithId(annotationId); +} diff --git a/app/assets/javascripts/thyme/attributes.js b/app/assets/javascripts/thyme/attributes.js index 5fc1415c9..4fb89656a 100644 --- a/app/assets/javascripts/thyme/attributes.js +++ b/app/assets/javascripts/thyme/attributes.js @@ -15,7 +15,7 @@ const thymeAttributes = { (which it is not for users who aren't signed in). */ annotationFeatureActive: false, - /* When callig the updateMarkers() method this will be used to save an + /* When calling the updateMarkers() method this will be used to save an array containing all annotations. */ annotations: null, diff --git a/app/assets/javascripts/thyme/thyme_feedback.js b/app/assets/javascripts/thyme/thyme_feedback.js index 1749b3be0..09aee2485 100644 --- a/app/assets/javascripts/thyme/thyme_feedback.js +++ b/app/assets/javascripts/thyme/thyme_feedback.js @@ -27,8 +27,7 @@ $(document).on("turbolinks:load", function () { (new TimeButton("minus-ten", -10)).add(); // Sliders (new VolumeBar("volume-bar")).add(); - seekBar = new SeekBar("seek-bar"); - seekBar.add(); + (new SeekBar("seek-bar")).add(); // heatmap const heatmap = new Heatmap("heatmap"); @@ -84,6 +83,11 @@ $(document).on("turbolinks:load", function () { thymeAttributes.annotationManager = annotationManager; thymeAttributes.annotationFeatureActive = true; + // update annotations manually once as initialization + annotationManager.updateAnnotations(() => { + openAnnotationIfSpecifiedInUrl(); + }); + /* KEYBOARD SHORTCUTS */ diff --git a/app/assets/javascripts/thyme/thyme_player.js b/app/assets/javascripts/thyme/thyme_player.js index a428023c9..7fc38d35c 100644 --- a/app/assets/javascripts/thyme/thyme_player.js +++ b/app/assets/javascripts/thyme/thyme_player.js @@ -95,6 +95,11 @@ $(document).on("turbolinks:load", function () { onClick, onUpdate, isValid); thymeAttributes.annotationManager = annotationManager; + // update annotations manually once as initialization + annotationManager.updateAnnotations(() => { + openAnnotationIfSpecifiedInUrl(); + }); + // Update annotations after deleting an annotation const ANNOTATION_DELETE_SELECTOR = "#annotation-delete-button"; $(document).on("click", ANNOTATION_DELETE_SELECTOR, function () { diff --git a/app/assets/stylesheets/annotations.scss b/app/assets/stylesheets/annotations.scss index 946dd05aa..aafe12d86 100644 --- a/app/assets/stylesheets/annotations.scss +++ b/app/assets/stylesheets/annotations.scss @@ -5,14 +5,6 @@ font-size: 1.3rem; padding: 2px 8px; - i { - &::before { - color: transparent; - background-clip: text; - background-image: radial-gradient(at bottom right, rgb(30, 82, 141) 0%, rgb(237, 152, 189) 100%); - } - } - transition: filter 60ms ease-in-out; &:hover { diff --git a/app/assets/stylesheets/annotations_overview.scss b/app/assets/stylesheets/annotations_overview.scss new file mode 100644 index 000000000..7ff1fe21c --- /dev/null +++ b/app/assets/stylesheets/annotations_overview.scss @@ -0,0 +1,15 @@ +.subtle-background { + background-image: linear-gradient(30deg, #fafdff 12%, transparent 12.5%, transparent 87%, #fafdff 87.5%, #fafdff), linear-gradient(150deg, #fafdff 12%, transparent 12.5%, transparent 87%, #fafdff 87.5%, #fafdff), linear-gradient(30deg, #fafdff 12%, transparent 12.5%, transparent 87%, #fafdff 87.5%, #fafdff), linear-gradient(150deg, #fafdff 12%, transparent 12.5%, transparent 87%, #fafdff 87.5%, #fafdff), linear-gradient(60deg, #fafdff77 25%, transparent 25.5%, transparent 75%, #fafdff77 75%, #fafdff77), linear-gradient(60deg, #fafdff77 25%, transparent 25.5%, transparent 75%, #fafdff77 75%, #fafdff77); + background-size: 42px 74px; + background-position: 0 0, 0 0, 21px 37px, 21px 37px, 0 0, 21px 37px; +} + +.annotation-overview-item { + border-width: 1.5px; + cursor: pointer; + transition: box-shadow 120ms cubic-bezier(0.33, 1, 0.68, 1); + + &:hover { + box-shadow: rgba(0, 0, 0, 0.23) 1px 2px 8px -2px; + } +} \ No newline at end of file diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index e8b97ca3e..7a69c64f8 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -64,8 +64,6 @@ $container-max-widths: ( @import "vertices"; @import "trix"; -$badge-color: #545b62; - trix-toolbar .trix-button-row { flex-wrap: wrap; } @@ -267,15 +265,8 @@ a { } } -.badge { - transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; - - &:hover, - &:focus { - text-decoration: none; - background-color: $badge-color !important; - color: white; - } +.badge:hover { + cursor: default; } .btn { diff --git a/app/assets/stylesheets/lectures.scss b/app/assets/stylesheets/lectures.scss index 2ee04b58b..be454a4d7 100644 --- a/app/assets/stylesheets/lectures.scss +++ b/app/assets/stylesheets/lectures.scss @@ -69,9 +69,14 @@ .lecture-tag { background-color: #dae0e5; + transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; - &:hover { + &:hover, + &:focus { + text-decoration: none; + background-color: #545b62 !important; color: white !important; + cursor: pointer !important; } } diff --git a/app/assets/stylesheets/media.scss b/app/assets/stylesheets/media.scss index 3d06e221f..06779bf24 100644 --- a/app/assets/stylesheets/media.scss +++ b/app/assets/stylesheets/media.scss @@ -1,6 +1,30 @@ -// Place all the styles related to the media controller here. -// They will automatically be included in application.css. -// You can use Sass (SCSS) here: http://sass-lang.com/ +@import "bootstrap/functions"; +@import "bootstrap/variables"; +@import "bootstrap/mixins"; + +.media-grid { + @include make-col-ready(); + + @include media-breakpoint-up(sm) { + @include make-col(12); + } + + @include media-breakpoint-up(md) { + @include make-col(6); + } + + @media (min-width: 1100px) { + @include make-col(4); + } + + @media (min-width: 1300px) { + @include make-col(3); + } + + @media (min-width: 1700px) { + @include make-col(2.4); // 5 columns + } +} .hidden { display: none; @@ -28,6 +52,7 @@ display: flex; justify-content: flex-start; flex-wrap: wrap; + padding: 0.5rem var(--bs-card-cap-padding-x); } .media-card-header { @@ -35,18 +60,133 @@ justify-content: flex-end; } -.card-header { +.mampf-card-header { padding: 0.75rem 1.25rem; + background-color: #223e62; +} + +.mampf-header-end { + color: $gray-300; + + & a { + color: $gray-300; + } } .card-footer { - padding: 0.75rem 1.25rem; + padding: 0; + background-color: #21252908; +} + +.media-hr { + margin: 0; +} + +.card-text-end { + padding: 0.75rem var(--bs-card-cap-padding-x); +} + +.media-download-buttons { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: space-around; + margin: 0.25rem; +} + +.download-button { + padding: 0 0.5em; + font-size: 0.8em; + height: 1.4em; + border: none; + text-align: left; + line-height: normal; +} + +.mampf-card { + border: 2px solid #223e62; + position: relative; + overflow: hidden; + background: linear-gradient(0deg, #ecf1f8 0%, #fcfdfd 60%); + box-shadow: rgba(0, 0, 0, 0.05) 0px 1px 3px; + transition: box-shadow 120ms ease-in-out; + + &:hover { + box-shadow: rgba(50, 50, 93, 0.02) 0px 50px 50px -20px, + rgba(0, 0, 0, 0.15) 0px 25px 50px -25px; + } +} + +.mampf-card:hover::after { + opacity: 0.12; +} + +.mampf-card::after { + content: ''; + position: absolute; + width: 160px; + height: 160px; + top: calc(var(--y, 0) * 1px - 80px); + left: calc(var(--x, 0) * 1px - 80px); + opacity: 0; + background: radial-gradient(white, #00000000 80%); + transition: opacity 200ms; + pointer-events: none; +} + +.mampf-card-image-wrapper { + overflow: hidden; + display: flex; + justify-content: center; + position: relative; + + &:hover .interactive-hover { + visibility: visible; + opacity: 1; + } +} + +.mampf-card-image { + margin: -5px; // crop image to not display scroll bars present in some images + width: 100%; +} + +.interactive-hover { + width: 100%; + height: 100%; + position: absolute; + display: flex; + justify-content: center; + align-items: center; + background-color: rgba(0, 0, 0, 0.75); + visibility: hidden; + opacity: 0; + transition: visibility 150ms ease-in-out, opacity 150ms ease-in-out; +} + +.interactive-hover-icon { + font-size: 2.3em; + color: white; +} + +.empty-default-screenshot { + width: 100%; + height: 8em; + background-color: #ffffff; + background-image: linear-gradient(#BCC5CF 2px, transparent 2px), linear-gradient(90deg, #BCC5CF 2px, transparent 2px), linear-gradient(#BCC5CF 1px, transparent 1px), linear-gradient(90deg, #BCC5CF 1px, #ffffff 1px); + background-size: 50px 50px, 50px 50px, 10px 10px, 10px 10px; + background-position: -2px -2px, -2px -2px, -1px -1px, -1px -1px; +} + +.card-body { + // same as card-footer border + border-top: var(--bs-card-border-width) solid var(--bs-card-border-color); } #media-card-subheader { display: flex; justify-content: space-between; - word-break: break-all; + word-break: auto-phrase; } #video-wait { diff --git a/app/controllers/annotations_controller.rb b/app/controllers/annotations_controller.rb index 3b65c3a02..67d579757 100644 --- a/app/controllers/annotations_controller.rb +++ b/app/controllers/annotations_controller.rb @@ -1,6 +1,18 @@ class AnnotationsController < ApplicationController authorize_resource + def index + @annotations_by_lecture = annotations_by_lecture(current_user.own_annotations) + + @show_students_annotations = current_user.teachable_editor_or_teacher? + if @show_students_annotations + @student_annotations_by_lecture = + annotations_by_lecture(current_user.students_annotations, is_shared: true) + end + + render layout: "application_no_sidebar_with_background" + end + def show @annotation = Annotation.find(params[:id]) end @@ -208,4 +220,29 @@ def commontator_comment(annotation) annotation.public_comment_id = comment.id end + + def annotations_by_lecture(annotations, is_shared: false) + annotations + .map { |a| extract_annotation_overview_information(a, is_shared) } + .group_by { |annotation| annotation[:lecture] } + .sort_by { |lecture, _annotations| lecture.updated_at }.reverse.to_h + # Instead of the full lecture object, we only need its title, + # and also not as value anymore for individual annotations. + .transform_keys(&:title) + .transform_values { |annos| annos.map { |a| a.except(:lecture) } } + .transform_values { |annos| annos.sort_by { |a| a[:created_at] }.reverse } + end + + def extract_annotation_overview_information(annotation, is_shared) + { + category: annotation.category, + text: annotation.comment_optional, + color: annotation.color, + updated_at: annotation.updated_at, + lecture: annotation.medium.teachable.lecture, + link: helpers.annotation_open_link(annotation, is_shared), + medium_title: annotation.medium.caption, + medium_date: annotation.medium.lesson&.date_localized + } + end end diff --git a/app/controllers/cypress/cypress_controller.rb b/app/controllers/cypress/cypress_controller.rb new file mode 100644 index 000000000..c108c4868 --- /dev/null +++ b/app/controllers/cypress/cypress_controller.rb @@ -0,0 +1,27 @@ +module Cypress + # Handles Cypress requests for interactive UI testing. + # + # The main purpose of this class is to send back errors as JSON object + # to parse them in the Cypress test UI. This way, we can display the error + # message and the stacktrace in the Cypress test. + # + # The convention with the frontend is to return the status `created` + # for successful requests and `bad_request` (or anything else) for failed requests. + class CypressController < ApplicationController + respond_to :json + rescue_from Exception, with: :show_errors + skip_before_action :authenticate_user! + + private + + # Returns the error as JSON such that it can be displayed in the Cypress test. + def show_errors(exception) + error = { + error: "#{exception.class}: #{exception}", + stacktrace: exception.backtrace.join("\n") + } + + render json: error, status: :bad_request + end + end +end diff --git a/app/controllers/cypress/database_cleaner_controller.rb b/app/controllers/cypress/database_cleaner_controller.rb new file mode 100644 index 000000000..21748c015 --- /dev/null +++ b/app/controllers/cypress/database_cleaner_controller.rb @@ -0,0 +1,10 @@ +module Cypress + # Cleans the database for use in Cypress tests. + class DatabaseCleanerController < CypressController + def create + res = DatabaseCleaner.clean_with(:truncation) + + render json: res.to_json, status: :created + end + end +end diff --git a/app/controllers/cypress/factories_controller.rb b/app/controllers/cypress/factories_controller.rb new file mode 100644 index 000000000..157e9e331 --- /dev/null +++ b/app/controllers/cypress/factories_controller.rb @@ -0,0 +1,50 @@ +module Cypress + # Handles Cypress requests to create factories via FactoryBot. + # See the factorybot.js file in the Cypress support folder. + # + # It is inspired by this blog post by Tom Conroy: + # https://tbconroy.com/2018/04/07/creating-data-with-factorybot-for-rails-cypress-tests/ + class FactoriesController < CypressController + # Wrapper around FactoryBot.create to create a factory via a POST request. + def create + unless params["0"].is_a?(String) + msg = "First argument must be a string indicating the factory name." + msg += " But we got: '#{params["0"]}'" + raise(ArgumentError, msg) + end + + attributes, should_validate = params_to_attributes(params.except(:controller, :action, + :number)) + + res = if should_validate + FactoryBot.create(*attributes) # default case + else + FactoryBot.build(*attributes).tap { |instance| instance.save(validate: false) } + end + + render json: res.to_json, status: :created + end + + private + + def params_to_attributes(params) + should_validate = true + + attributes = params.to_unsafe_hash.filter_map do |_key, value| + if value.is_a?(Hash) + if value.key?("validate") + should_validate = (value["validate"] != "false") + else + value.transform_keys(&:to_sym) + end + elsif value.is_a?(String) + value.to_sym + else + throw("Value is neither a hash nor a string: #{value}") + end + end + + return attributes, should_validate + end + end +end diff --git a/app/controllers/cypress/i18n_controller.rb b/app/controllers/cypress/i18n_controller.rb new file mode 100644 index 000000000..cc3e5ec1a --- /dev/null +++ b/app/controllers/cypress/i18n_controller.rb @@ -0,0 +1,26 @@ +module Cypress + # Allows to access i18n keys in Cypress tests. + class I18nController < CypressController + def create + unless params[:i18n_key].is_a?(String) + msg = "Argument `i18n_key` must be a string indicating the i18n key." + msg += " But we got: '#{params[:i18n_key]}'" + raise(ArgumentError, msg) + end + + substitutions = {} + if params[:substitutions].present? + unless params[:substitutions].is_a?(Hash) + msg = "Argument `substitution` must be a hash indicating the substitutions." + msg += " But we got: '#{params[:substitutions]}'" + raise(ArgumentError, msg) + end + substitutions = params[:substitutions].to_unsafe_hash.symbolize_keys + end + + i18n_key = params[:i18n_key] + + render json: I18n.t(i18n_key, **substitutions), status: :created + end + end +end diff --git a/app/controllers/cypress/user_creator_controller.rb b/app/controllers/cypress/user_creator_controller.rb new file mode 100644 index 000000000..f82b4dd18 --- /dev/null +++ b/app/controllers/cypress/user_creator_controller.rb @@ -0,0 +1,22 @@ +module Cypress + # Creates a user for use in Cypress tests. + class UserCreatorController < CypressController + def create + unless params[:role].is_a?(String) + msg = "First argument must be a string indicating the user role." + msg += " But we got: '#{params["0"]}'" + raise(ArgumentError, msg) + end + + role = params[:role] + is_admin = (role == "admin") + + user = User.create(name: "#{role} Cypress", email: "#{role}@mampf.cypress", + password: "cypress123", consents: true, admin: is_admin, + locale: I18n.default_locale) + user.confirm + + render json: user.to_json, status: :created + end + end +end diff --git a/app/controllers/lectures_controller.rb b/app/controllers/lectures_controller.rb index 719f59540..12fb42bdd 100644 --- a/app/controllers/lectures_controller.rb +++ b/app/controllers/lectures_controller.rb @@ -250,11 +250,13 @@ def close_comments @lecture.lessons.each do |lesson| lesson.media.update(annotations_status: -1) end + @lecture.touch redirect_to "#{edit_lecture_path(@lecture)}#communication" end def open_comments @lecture.open_comments!(current_user) + @lecture.touch redirect_to "#{edit_lecture_path(@lecture)}#communication" end diff --git a/app/controllers/media_controller.rb b/app/controllers/media_controller.rb index 2e8bf90fd..97d9950ab 100644 --- a/app/controllers/media_controller.rb +++ b/app/controllers/media_controller.rb @@ -524,6 +524,7 @@ def check_annotation_visibility # which has nothing to do with the thyme player(s). def feedback I18n.locale = @medium.locale_with_inheritance + @time = params[:time] render layout: "feedback" end diff --git a/app/helpers/annotations_helper.rb b/app/helpers/annotations_helper.rb new file mode 100644 index 000000000..2d7646467 --- /dev/null +++ b/app/helpers/annotations_helper.rb @@ -0,0 +1,22 @@ +require "uri" + +module AnnotationsHelper + def annotation_open_link(annotation, is_shared) + link = if is_shared + feedback_video_link_timed(annotation.medium_id, annotation.timestamp) + else + video_link_timed(annotation.medium_id, annotation.timestamp) + end + link = URI.parse(link) + link.query = link.query.present? ? "#{link.query}&ann=#{annotation.id}" : "ann=#{annotation.id}" + link.to_s + end + + def annotation_index_border_color(annotation, is_student_annotation) + # The border color of annotation cards shared by students, will be set + # via JS according to the color of the annotation CATEGORY. + return "" if is_student_annotation + + "border-color: #{annotation[:color]}" + end +end diff --git a/app/helpers/media_helper.rb b/app/helpers/media_helper.rb index fe432a51f..8bb3a8924 100644 --- a/app/helpers/media_helper.rb +++ b/app/helpers/media_helper.rb @@ -149,4 +149,12 @@ def external_link_description_not_empty(medium) # link url itself. medium.external_link_description.presence || medium.external_reference_link end + + def video_link_timed(medium, timestamp) + play_medium_path(medium, params: { time: timestamp.total_seconds }) + end + + def feedback_video_link_timed(medium, timestamp) + feedback_medium_path(medium, params: { time: timestamp.total_seconds }) + end end diff --git a/app/helpers/talks_helper.rb b/app/helpers/talks_helper.rb index 8f17c847b..4546dba81 100644 --- a/app/helpers/talks_helper.rb +++ b/app/helpers/talks_helper.rb @@ -18,9 +18,9 @@ def speaker_list(talk) end def speaker_icon_class(talk) - return "fas fa-user" unless talk.speakers.count > 1 + return "bi bi-person-fill" unless talk.speakers.count > 1 - "fas fa-users" + "bi bi-people-fill" end def speaker_icon(talk) diff --git a/app/models/assignment.rb b/app/models/assignment.rb index 40f143d54..2b2792828 100644 --- a/app/models/assignment.rb +++ b/app/models/assignment.rb @@ -20,9 +20,9 @@ def deletion_date_cannot_be_in_the_past "in_past")) end - scope :active, -> { where("deadline >= ?", Time.zone.now) } + scope :active, -> { where(deadline: Time.zone.now..) } - scope :expired, -> { where("deadline < ?", Time.zone.now) } + scope :expired, -> { where(deadline: ...Time.zone.now) } def self.accepted_file_types [".pdf", ".tar.gz", ".cc", ".hh", ".m", ".mlx", ".zip"] diff --git a/app/models/medium.rb b/app/models/medium.rb index a2e9e2b5e..ab667c7cb 100644 --- a/app/models/medium.rb +++ b/app/models/medium.rb @@ -121,7 +121,7 @@ class Medium < ApplicationRecord } scope :proper, -> { where.not(sort: "RandomQuiz") } scope :expired, lambda { - where(sort: "RandomQuiz").where("created_at < ?", 1.day.ago) + where(sort: "RandomQuiz").where(created_at: ...1.day.ago) } searchable do diff --git a/app/models/user.rb b/app/models/user.rb index d50f4a158..3952d0d1d 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -807,6 +807,19 @@ def last_sign_in_ip=(_ip) def current_sign_in_ip=(_ip) end + ############################################################################## + # Annotations + ############################################################################## + + def own_annotations + Annotation.where(user: self) + end + + def students_annotations + Annotation.where(medium_id: medium_ids_of_lectures_or_edited_lectures, + visible_for_teacher: true) + end + private def set_defaults @@ -865,4 +878,9 @@ def archive_user(archive_name) confirmed_at: Time.zone.now, archived: true) end + + def medium_ids_of_lectures_or_edited_lectures + lectures = given_lectures + edited_lectures + lectures.flat_map(&:media_with_inheritance).pluck(:id) + end end diff --git a/app/views/annotations/_annotation_area.html.erb b/app/views/annotations/_annotation_area.html.erb index ab0cfa8e8..2e0327e89 100644 --- a/app/views/annotations/_annotation_area.html.erb +++ b/app/views/annotations/_annotation_area.html.erb @@ -2,7 +2,7 @@
-
+
diff --git a/app/views/annotations/_index_accordion.html.erb b/app/views/annotations/_index_accordion.html.erb new file mode 100644 index 000000000..a8f4da4ae --- /dev/null +++ b/app/views/annotations/_index_accordion.html.erb @@ -0,0 +1,73 @@ +<% accordionLabel = "annotations-overview-accordion" %> +<% accordionLabel = "student-#{accordionLabel}" if is_students_annotations %> +
+ + <% annotations_by_lecture.each_with_index do |(lecture, annotations), index| %> + + <% end %> + +
diff --git a/app/views/annotations/index.html.erb b/app/views/annotations/index.html.erb new file mode 100644 index 000000000..471820030 --- /dev/null +++ b/app/views/annotations/index.html.erb @@ -0,0 +1,67 @@ +<%= stylesheet_link_tag 'annotations_overview' %> + +
+ + + + + + <% if @show_students_annotations %> + +

<%= t('admin.annotation.students_annotations') %>

+ + <% if @student_annotations_by_lecture.empty? %> + +
+ +

+ <%= t('admin.annotation.none_yet_students') %> +

+
+ + <% else %> + +

+ <%= t('admin.annotation.overview_description_students_annotations') %> +

+ + <%= render 'annotations/index_accordion', + annotations_by_lecture: @student_annotations_by_lecture, + is_students_annotations: true %> + + <% end %> + +
+ <% end %> + + + + + +

<%= t('admin.annotation.your_annotations') %>

+ + <% if @annotations_by_lecture.empty? %> + +
+ +

+ <%= t('admin.annotation.none_yet_html') %> +

+
+ + <% else %> + +

+ <%= t('admin.annotation.overview_description') %> +

+ + <%= render 'annotations/index_accordion', + annotations_by_lecture: @annotations_by_lecture, + is_students_annotations: false %> + + <% end %> + + +
+ +<%= javascript_include_tag :annotations_overview %> diff --git a/app/views/commontator/comments/_body.html.erb b/app/views/commontator/comments/_body.html.erb index f6e3df238..76235cb74 100644 --- a/app/views/commontator/comments/_body.html.erb +++ b/app/views/commontator/comments/_body.html.erb @@ -12,6 +12,5 @@ <% timestamp = annotation.timestamp %>
Timestamp: - - <%= timestamp.hms_colon_string %> + <%= link_to timestamp.hms_colon_string, video_link_timed(medium, timestamp) %> <% end %> diff --git a/app/views/lectures/edit/_comments.html.erb b/app/views/lectures/edit/_comments.html.erb index 1018f65e1..60af9b03f 100644 --- a/app/views/lectures/edit/_comments.html.erb +++ b/app/views/lectures/edit/_comments.html.erb @@ -5,12 +5,19 @@
- <%= link_to t('buttons.close_comments'), - lecture_close_comments_path(lecture), - class: 'btn btn-sm btn-outline-secondary' %> - <%= link_to t('buttons.open_comments'), - lecture_open_comments_path(lecture), - class: 'btn btn-sm btn-outline-secondary' %> + <% if lecture.comments_closed? %> + <%= link_to lecture_open_comments_path(lecture), + class: "btn btn-sm btn-outline-secondary", role: :button do %> + + <%= t('buttons.open_comments') %> + <% end %> + <% else %> + <%= link_to lecture_close_comments_path(lecture), + class: "btn btn-sm btn-outline-secondary", role: :button do %> + + <%= t('buttons.close_comments') %> + <% end %> + <% end %>
diff --git a/app/views/media/_media.html.erb b/app/views/media/_media.html.erb index c70bf99c9..2a5b1e3f6 100644 --- a/app/views/media/_media.html.erb +++ b/app/views/media/_media.html.erb @@ -1,7 +1,7 @@ <% if media.present? %>
<% media.each do |medium| %> -
+
<%= render partial: 'media/medium', locals: { medium: medium, tags: tags, diff --git a/app/views/media/_medium.html.erb b/app/views/media/_medium.html.erb index d25bd05c1..a23a6cca3 100644 --- a/app/views/media/_medium.html.erb +++ b/app/views/media/_medium.html.erb @@ -1,7 +1,10 @@ +<%= javascript_include_tag 'cards' %> + <% I18n.with_locale(medium.locale_with_inheritance) do %> -
-
-
+
+
+
<% if lecture&.imported_media&.include?(medium) %> <% end %>
-
+ +
+ <% if current_user.locale.to_s != medium.locale_with_inheritance %> - "> <%= medium.locale_with_inheritance.to_s %> <% end %> + + <% if !medium.published? && !medium.publisher %> - <% elsif !medium.published? && medium.publisher.present? %> - <% elsif medium.locked? %> - @@ -56,57 +62,99 @@ <% elsif lecture && medium.teachable_type == 'Course' && !lecture.imported_media&.include?(medium) && (current_user.admin || lecture.edited_by?(current_user)) && (medium.tags & lecture.tags_including_media_tags).blank? %> - <% end %> - <% if !medium.containing_watchlists(current_user).empty? %> + + + <% if from == "watchlist" %> + <% if @watchlist.owned_by?(current_user) %> + <%= link_to '', + watchlist_entry_path(entry, + watchlist: @watchlist.id, + reverse: params[:reverse], + all: params[:all], + page: params[:page], + per: params[:per]), + class: 'btn bi bi-x-circle-fill', + style: 'text-decoration: none;', + data: { toggle: 'tooltip', + placement: 'bottom', + confirm: t('watchlist_entry.remove_confirm') }, + title: t('watchlist_entry.remove'), + method: :delete %> + <% end %> + <% else %> + <% if !medium.containing_watchlists(current_user).empty? %> + <%= link_to '', + watchlist_path(medium.containing_watchlists(current_user).first), + class: 'btn p-2 bi bi-bookmark-fill', + data: { toggle: 'tooltip', + placement: 'bottom' }, + title: t('watchlist_entry.list', + count: medium.containing_watchlists(current_user).size, + watchlists: medium.containing_watchlists_names(current_user).join(", ")) %> + <% end %> <%= link_to '', - watchlist_path(medium.containing_watchlists(current_user).first), - class: 'fas fa-bookmark text-light me-2', - style: 'text-decoration: none;', + add_medium_to_watchlist_path(medium), + class: 'btn p-2 bi bi-tv-fill', data: { toggle: 'tooltip', placement: 'bottom' }, - title: t('watchlist_entry.list', - count: medium.containing_watchlists(current_user).size, - watchlists: medium.containing_watchlists_names(current_user).join(", ")) %> + title: t('watchlist.add'), + remote: true %> <% end %> - <%= link_to '', - add_medium_to_watchlist_path(medium), - class: 'fas fa-list text-light me-2', - style: 'text-decoration: none;', - data: { toggle: 'tooltip', - placement: 'bottom' }, - title: t('watchlist.add'), - remote: true %>
-
<% if medium.teachable.is_a?(Talk) %>
<%= speaker_list_with_icon(medium.teachable) %>
<% end %> -
- <% cache [medium, from == 'tag', ref_link] do %> - <% unless from == 'tag' %> + + <% cache [medium, ref_link] do %> +
+ + <% if medium.video.present? %> + + + + <% elsif medium.manuscript.present? %> + + + + <% elsif medium.sort == 'Quiz' && medium.quiz_graph %> + + + + <% end %> + + <% if medium.screenshot.present? %> <%= image_tag(medium.screenshot_url_with_host, alt: "Card image cap", - class: "card-img-top") %> + class: "mampf-card-image") %> <% elsif medium.manuscript.present? %> <%= image_tag(medium.manuscript_screenshot_url || '', alt: "Card image cap", - class: "card-img-top manuscript-thumbnail") %> + class: "mampf-card-image manuscript-thumbnail") %> <% elsif medium.geogebra_screenshot_url.present? %> <%= image_tag(medium.geogebra_screenshot_url, alt: 'Card image cap', - class: 'card-img-top') %> + class: 'mampf-card-image') %> + <% else %> +
<% end %> - <% end %> +
+
<% unless medium.caption.nil? %>
- <% end %> \ No newline at end of file diff --git a/app/views/media/feedback.html.erb b/app/views/media/feedback.html.erb index 5bc8367df..eb92333bf 100644 --- a/app/views/media/feedback.html.erb +++ b/app/views/media/feedback.html.erb @@ -17,7 +17,7 @@ play_arrow - + 0:00:00 diff --git a/app/views/media/medium/_buttons.html.erb b/app/views/media/medium/_buttons.html.erb index c1c4317fd..b5d4aa09c 100644 --- a/app/views/media/medium/_buttons.html.erb +++ b/app/views/media/medium/_buttons.html.erb @@ -1,124 +1,81 @@ <% if medium.video.present? %> -
+ <% end %> <% if medium.manuscript.present? %> -
+ <% end %> <% if medium.geogebra.present? %> -
+ <% end %> <% if medium.external_reference_link.present? %> -
+ <% end %> <% if medium.sort == 'Quiz' && medium.quiz_graph %> -
+ <% end %> -
+ -<% if medium.video.present? %> - diff --git a/app/views/media/play.html.erb b/app/views/media/play.html.erb index 52b838860..8caf53605 100644 --- a/app/views/media/play.html.erb +++ b/app/views/media/play.html.erb @@ -18,7 +18,7 @@ play_arrow - + 0:00:00 @@ -66,7 +66,7 @@ <% if user_signed_in? %> - + <% end %> diff --git a/app/views/shared/_navbar.html.erb b/app/views/shared/_navbar.html.erb index 73e98ebf1..34b89358f 100644 --- a/app/views/shared/_navbar.html.erb +++ b/app/views/shared/_navbar.html.erb @@ -43,6 +43,14 @@ <% end %> + +