diff --git a/.dockerfiles/Dockerfile b/.dockerfiles/Dockerfile index 390b16cc39..0c9e85dfd2 100644 --- a/.dockerfiles/Dockerfile +++ b/.dockerfiles/Dockerfile @@ -1,6 +1,6 @@ FROM ubuntu:jammy AS base -ARG NODE_MAJOR=18 +ARG NODE_MAJOR=20 ARG BUNDLER_VERSION='2.4.13' ARG USER=markus diff --git a/.dockerfiles/entrypoint-dev-rails.sh b/.dockerfiles/entrypoint-dev-rails.sh index 5152b0909d..b7a5b9bfc1 100755 --- a/.dockerfiles/entrypoint-dev-rails.sh +++ b/.dockerfiles/entrypoint-dev-rails.sh @@ -9,9 +9,9 @@ npm list &> /dev/null || npm ci # install python packages [ -f ./venv/bin/python3 ] || python3 -m venv ./venv ./venv/bin/python3 -m pip install --upgrade pip > /dev/null -./venv/bin/python3 -m pip install -r requirements-jupyter.txt > /dev/null -./venv/bin/python3 -m pip install -r requirements-scanner.txt > /dev/null -./venv/bin/python3 -m pip install -r requirements-qr.txt > /dev/null +./venv/bin/python3 -m pip install -r requirements-jupyter.txt +./venv/bin/python3 -m pip install -r requirements-scanner.txt +./venv/bin/python3 -m pip install -r requirements-qr.txt # install chromium (for nbconvert webpdf conversion) ./venv/bin/python3 -m playwright install chromium @@ -31,5 +31,8 @@ sed -i -e :a -e '/^\n*$/{$d;N;};/\n$/ba' /app/db/structure.sql rm -f ./tmp/pids/server.pid -# Then exec the container's main process (what's set as CMD in the Dockerfile or docker-compose.yml). +# cssbundling-rails development command +npm run build-dev:css & + +# Then exec the container's main process (what's set as CMD in the Dockerfile or compose.yaml). exec "$@" diff --git a/.dockerfiles/production_demo/entrypoint-prod-rails.sh b/.dockerfiles/production_demo/entrypoint-prod-rails.sh index e3fcc6acc4..2a16bfaa8f 100755 --- a/.dockerfiles/production_demo/entrypoint-prod-rails.sh +++ b/.dockerfiles/production_demo/entrypoint-prod-rails.sh @@ -9,5 +9,5 @@ bundle exec rails db:prepare rm -f ./tmp/pids/server.pid -# Then exec the container's main process (what's set as CMD in the Dockerfile or docker-compose.yml). +# Then exec the container's main process (what's set as CMD in the Dockerfile or compose.yaml). exec "$@" diff --git a/.dockerignore b/.dockerignore index 2d2c17bc56..271ea2caba 100644 --- a/.dockerignore +++ b/.dockerignore @@ -82,3 +82,4 @@ app/javascript/routes.d.ts # local docker compose overrides docker-compose.override.yml +compose.override.yaml diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 2617733a80..8c8b970336 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,50 +1,54 @@ - - +## Proposed Changes +*(Describe your changes here. Also describe the motivation for your changes: what problem do they solve, or how do they improve the application or codebase? If this pull request fixes an open issue, [use a keyword to link this pull request to the issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword).)* -## Motivation and Context - - +... +
+Screenshots of your changes (if applicable) -## Your Changes - - -**Description**: +
+
+Associated documentation repository pull request (if applicable) -**Type of change** (select all that apply): - - +
-- [ ] Bug fix (non-breaking change which fixes an issue) -- [ ] New feature (non-breaking change which adds functionality) -- [ ] Breaking change (fix or feature that would cause existing functionality to change) -- [ ] Refactoring (internal change to codebase, without changing functionality) -- [ ] Test update (change that modifies or updates tests only) -- [ ] Other (please specify): +## Type of Change +*(Write an `X` or a brief description next to the type or types that best describe your changes.)* +| Type | Applies? | +|-----------------------------------------------------------------------------------------|----------| +| 🚨 *Breaking change* (fix or feature that would cause existing functionality to change) | | +| ✨ *New feature* (non-breaking change that adds functionality) | | +| 🐛 *Bug fix* (non-breaking change that fixes an issue) | | +| 🎨 *User interface change* (change to user interface; provide screenshots) | | +| ♻️ *Refactoring* (internal change to codebase, without changing functionality) | | +| 🚦 *Test update* (change that *only* adds or modifies tests) | | +| 📦 *Dependency update* (change that updates a dependency) | | +| 🔧 *Internal* (change that *only* affects developers or continuous integration) | | -## Testing - - +## Checklist +*(Complete each of the following items for your pull request. Indicate that you have completed an item by changing the `[ ]` into a `[x]` in the raw text, or by clicking on the checkbox in the rendered description on GitHub.)* -## Questions and Comments (if applicable) - - - - +Before opening your pull request: +- [ ] I have performed a self-review of my changes. + - Check that all changed files included in this pull request are intentional changes. + - Check that all changes are relevant to the purpose of this pull request, as described above. +- [ ] I have added tests for my changes, if applicable. + - This is **required** for all bug fixes and new features. +- [ ] I have updated the project documentation, if applicable. + - This is **required** for new features. +- [ ] If this is my first contribution, I have added myself to the list of contributors. -## Checklist +After opening your pull request: -- [ ] I have performed a self-review of my own code. -- [ ] I have verified that the pre-commit.ci checks have passed. -- [ ] I have verified that the CI tests have passed. -- [ ] I have reviewed the test coverage changes reported on Coveralls. -- [ ] I have added tests for my changes. -- [ ] I have updated the Changelog.md file. -- [ ] I have made changes to the documentation and linked to that pull request below. +- [ ] I have updated the project Changelog (this is required for all changes). +- [ ] I have verified that the pre-commit.ci checks have passed. +- [ ] I have verified that the CI tests have passed. +- [ ] I have reviewed the test coverage changes reported by Coveralls. +- [ ] I have [requested a review](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/requesting-a-pull-request-review) from a project maintainer. -### Pull request to make documentation changes (if applicable) - +## Questions and Comments +*(Include any questions or comments you have regarding your changes.)* diff --git a/.github/workflows/test_ci.yml b/.github/workflows/test_ci.yml index bc03a60460..f35dc69957 100644 --- a/.github/workflows/test_ci.yml +++ b/.github/workflows/test_ci.yml @@ -41,7 +41,7 @@ jobs: RSPEC_RENDER_VIEWS: true steps: - name: Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Install system dependencies run: | sudo apt-get update @@ -52,26 +52,30 @@ jobs: ruby-version: ruby-3.0 bundler-cache: true - name: Set up node and cache packages - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 18 cache: npm - name: Install npm packages run: npm ci - name: Install python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: - python-version: 3.9 + python-version: "3.10" + - name: Get pip cache dir + id: pip-cache + run: | + echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT - name: Cache pip - uses: actions/cache@v3 + uses: actions/cache@v4 with: - path: ~/.cache/pip + path: ${{ steps.pip-cache.outputs.dir }} key: ${{ runner.os }}-pip-${{ hashFiles('requirements-jupyter.txt') }}-${{ hashFiles('requirements-scanner.txt') }}-${{ hashFiles('requirements-qr.txt') }} restore-keys: | ${{ runner.os }}-pip- - name: Install python packages and playwright dependencies run: | - python3.9 -m venv venv + python3.10 -m venv venv ./venv/bin/pip install -r requirements-jupyter.txt -r requirements-scanner.txt -r requirements-qr.txt ./venv/bin/playwright install chromium ./venv/bin/playwright install-deps chromium @@ -82,13 +86,13 @@ jobs: sudo sed -ri 's/(rights=")none("\s+pattern="PDF")/\1read\2/' /etc/ImageMagick-6/policy.xml cp config/database.yml.ci config/database.yml - name: Build assets - run: bundle exec rake javascript:build + run: | + bundle exec rake javascript:build + bundle exec rake css:build - name: Set up database run: bundle exec rails db:migrate - - name: Install chrome and chromedriver + - name: Install chromedriver uses: nanasess/setup-chromedriver@v2 - with: - chromedriver-version: '119.0.6045.199' - name: Run chromedriver run: chromedriver --whitelisted-ips & - name: Run rspec tests diff --git a/.gitignore b/.gitignore index 1e8075d7a6..88b9f0c57b 100644 --- a/.gitignore +++ b/.gitignore @@ -81,3 +81,4 @@ config/environments/*.local.yml # local docker compose overrides docker-compose.override.yml +compose.override.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 53808b9da4..72a408511d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v4.6.0 hooks: - id: check-yaml exclude: | @@ -10,20 +10,19 @@ repos: - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/pre-commit/mirrors-prettier - rev: v3.0.3 + rev: v3.1.0 hooks: - id: prettier types_or: [javascript, jsx, css, scss, html] - repo: https://github.com/thibaudcolas/pre-commit-stylelint - rev: v15.11.0 + rev: v16.6.1 hooks: - id: stylelint additional_dependencies: [ - "stylelint@14.11.0", - "postcss-scss@4.0.4", - "stylelint-config-sass-guidelines@9.0.1", - "stylelint-config-prettier@9.0.3", - "postcss@8.4.16", + "stylelint@16.3.1", + "postcss-scss@4.0.9", + "stylelint-config-sass-guidelines@11.1.0", + "postcss@8.4.38", ] args: ["--fix"] types_or: ["css", "scss"] @@ -33,7 +32,7 @@ repos: app/assets/stylesheets/common/_reset.scss )$ - repo: https://github.com/rubocop/rubocop - rev: v1.57.2 + rev: v1.64.1 hooks: - id: rubocop args: ["--autocorrect"] @@ -46,8 +45,11 @@ repos: Vagrantfile )$ additional_dependencies: - - rubocop-rails:2.19.1 - - rubocop-performance:1.17.1 + - rubocop-rails:2.24.1 + - rubocop-performance:1.21.0 + - rubocop-factory_bot:2.25.1 + - rubocop-rspec:2.28.0 + - rubocop-rspec_rails:2.28.2 exclude: vendor diff --git a/.rubocop.yml b/.rubocop.yml index 292600a1c5..06188e0ba1 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,6 +1,9 @@ require: - rubocop-rails - rubocop-performance + - rubocop-factory_bot + - rubocop-rspec + - rubocop-rspec_rails AllCops: Exclude: @@ -12,6 +15,15 @@ AllCops: SuggestExtensions: false TargetRubyVersion: 2.7 +Capybara/ClickLinkOrButtonStyle: # TODO: Enable and fix errors after reviewing system tests + Enabled: false + +FactoryBot/AssociationStyle: + EnforcedStyle: explicit + +FactoryBot/ConsistentParenthesesStyle: + EnforcedStyle: require_parentheses + Layout/EmptyLineAfterGuardClause: Enabled: false @@ -173,6 +185,77 @@ Rails/SkipsModelValidations: Rails/UniqueValidationWithoutIndex: Enabled: false +RSpec/AnyInstance: + Enabled: false + +RSpec/ContextWording: + Enabled: false + +RSpec/DescribeClass: # Disabled because some suites are note testing classes or modules + Enabled: false + +RSpec/DescribedClass: + EnforcedStyle: explicit + +RSpec/EmptyExampleGroup: # Does not recognize ActionPolicy tests (succeed/failed) + Enabled: false + +RSpec/ExampleLength: + Enabled: false + +RSpec/ExampleWording: + Enabled: false + +RSpec/ExpectChange: + EnforcedStyle: block + +RSpec/FactoryBot: # Extracted into rubocop-factory_bot gem + Enabled: false + +RSpec/IndexedLet: + Enabled: false + +RSpec/InstanceVariable: # TODO: enable this check and fix the errors + Enabled: false + +RSpec/MatchArray: + Enabled: false + +RSpec/MessageSpies: + EnforcedStyle: receive + +RSpec/MetadataStyle: + EnforcedStyle: hash + +RSpec/MultipleExpectations: + Enabled: false + +RSpec/MultipleMemoizedHelpers: + Enabled: false + +RSpec/NamedSubject: + Enabled: false + +RSpec/NestedGroups: + Enabled: false + +RSpec/PendingWithoutReason: + Enabled: false + +RSpec/Rails: # Extracted into rubocop-factory_bot gem + Enabled: false + +RSpec/ScatteredLet: + Exclude: + - spec/policies/**/*.rb + +RSpec/ScatteredSetup: + Exclude: + - spec/policies/**/*.rb + +RSpec/StubbedMock: + Enabled: false + Security/IoMethods: Enabled: true diff --git a/.stylelintrc.yaml b/.stylelintrc.yaml index 6d77ab05dd..6035974dee 100644 --- a/.stylelintrc.yaml +++ b/.stylelintrc.yaml @@ -1,6 +1,7 @@ -extends: ["stylelint-config-sass-guidelines", "stylelint-config-prettier"] +extends: ["stylelint-config-sass-guidelines"] rules: scss/at-extend-no-missing-placeholder: null + scss/no-global-function-names: null max-nesting-depth: 4 selector-class-pattern: null selector-max-compound-selectors: 6 diff --git a/Changelog.md b/Changelog.md index fd24cac017..f0a460272c 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,5 +1,58 @@ # Changelog +## [v2.5.0] + +### ✨ New features and improvements + +- Allow deletion of assignments with no groups (#6880) +- Add new routes for `update`, `show`, and `index` actions of the Sections API Controller (#6955) +- Enable the deletion of Grade Entry Forms that have no grades (#6915) +- Allow instructors to configure an end date until which students can run automated tests (#6992) +- Gave TAs read-only access to starter file information under assignment settings (#6996) +- Allow inactive groups in the submissions table to be toggled for display (#7000) +- Display error message for student-run tests when no test groups are runnable (#7003) +- Added a confirmation check while renaming a file with a different extension (#7024) +- Allow inactive groups in the summary table to be toggled for display (#7029) +- Display error message for instructor-run tests when no test groups are runnable by instructors (#7038) +- Ensure user params are passed as keyword arguments to database queries (#7040) +- Added a progress bar for when a student uploads a file for submission (#7078) +- Added validations to the `TextAnnotation` model to ensure `line_start` and `line_end` are >= 1, and `column_start` and `column_end` are >= 0. (#7081) +- Calculate and display the exact future time for the next token generation to help students plan their test runs more effectively. (#7127) +- Set the default date for "Tokens available on" to the current time (#7173). +- Add setting to enable filtering of new course creation from LTI launch (#7151) + +### 🐛 Bug fixes + +- Ensure annotation previews are rendered on annotations page and in annotation category dropdown menus when grading (#7073) +- Ensure annotation rendering is updated in real-time for Jupyter notebooks (#7084) +- Fix MathJax rendering in annotations for Jupyter notebooks (#7084) +- Fix MathJax rendering of released overall comments (#7084) +- Fix rename confirmation check triggering even upon no rename input (#7124) +- Ensured that the assignment deadline is used as a placeholder if token_end_date is not present. (#7128) +- Fixed grader view rendering when a pre-defined annotation's content is blank (#7147) +- Fixed AJAX requests in grader view, which were missing CSRF token headers (#7174) +- Use jQuery `.find` method in `ModalMarkus` to guard against potential XSS attack (#7176) +- Sanitize LTI deployment course names when creating new courses (#7151) + +### 🔧 Internal changes + +- Fixed login_spec.rb flaky test on GitHub Actions run (#6966) +- Minor improvements in assignment association options (#6989) +- Update changelog and pull request template formats (#7041) +- Upgrade to MathJax version 3, with packaging from npm (#7073) +- Upgrade CI chromedriver to 125.0.6422.60 (#7082) +- Fix flaky `Assignment#summary_json` test (#7111) +- Upgrade pdfjs-dist to v4.3.136 (#7113) +- Fixed formatting of documentation repository on the PR template (#7120) +- Switch from rails-sassc to cssbundling-rails for CSS asset management (#7121) +- Fixed flaky test #creates groups for individual students in groups_controller_spec (#7145) +- Switch from SyntaxHighlighter to Prism for syntax highlighting (#7122) +- Move jquery-ui and ui-contextmenu dependencies to package.json and upgrade jquery-ui to v1.13.3 (#7149) +- Do not enforce secure cookies in development for LTI deployments (#7151) +- Remove CI chromedriver version and Chrome dependency (#7170) +- Update Jupyter notebook Javascript dependencies (require.js to v2.3.7, plotly.js to v2.34.0) (#7175) +- Do not require i18n-tasks in Gemfile (#7180) + ## [v2.4.12] ### ✨ New features and improvements diff --git a/Gemfile b/Gemfile index 397ad960bb..bb62bc0e7a 100644 --- a/Gemfile +++ b/Gemfile @@ -8,7 +8,7 @@ source 'https://rubygems.org' # Bundler requires these gems in all environments gem 'puma' -gem 'rails', '~> 7.0.8' +gem 'rails', '~> 7.1.3' gem 'sprockets' gem 'sprockets-rails' @@ -19,7 +19,6 @@ gem 'pluck_to_hash' gem 'autoprefixer-rails' gem 'jsbundling-rails' gem 'js-routes' -gem 'sass-rails' gem 'terser' # Background tasks @@ -47,7 +46,7 @@ gem 'redis', '~> 4.8.1' gem 'combine_pdf' gem 'prawn' gem 'prawn-qrcode' -gem 'rmagick', '~> 5.3.0' +gem 'rmagick', '~> 6.0.1' gem 'rtesseract' # Ruby miscellany @@ -62,6 +61,7 @@ gem 'activemodel-serializers-xml' gem 'activerecord-session_store' gem 'config' gem 'cookies_eu' +gem 'dry-validation' # For settings schema validation gem 'exception_notification' gem 'marcel' gem 'rails-html-sanitizer' @@ -104,8 +104,8 @@ group :development, :test do gem 'bullet' gem 'capybara' gem 'debug', '>= 1.0.0' - gem 'i18n-tasks' - gem 'rspec-rails', '~> 6.1.0' + gem 'i18n-tasks', require: false + gem 'rspec-rails', '~> 6.1.3' gem 'selenium-webdriver' end @@ -123,3 +123,5 @@ end group :unicorn do gem 'unicorn' end + +gem 'cssbundling-rails', '~> 1.4' diff --git a/Gemfile.lock b/Gemfile.lock index d3932f4a7a..59a9f66a1b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,68 +1,73 @@ GEM remote: https://rubygems.org/ specs: - action_policy (0.6.7) - ruby-next-core (>= 0.14.0) - actioncable (7.0.8) - actionpack (= 7.0.8) - activesupport (= 7.0.8) + action_policy (0.7.1) + ruby-next-core (>= 1.0) + actioncable (7.1.3.4) + actionpack (= 7.1.3.4) + activesupport (= 7.1.3.4) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (7.0.8) - actionpack (= 7.0.8) - activejob (= 7.0.8) - activerecord (= 7.0.8) - activestorage (= 7.0.8) - activesupport (= 7.0.8) + zeitwerk (~> 2.6) + 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.0.8) - actionpack (= 7.0.8) - actionview (= 7.0.8) - activejob (= 7.0.8) - activesupport (= 7.0.8) + 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.0) - actionpack (7.0.8) - actionview (= 7.0.8) - activesupport (= 7.0.8) - rack (~> 2.0, >= 2.2.4) + rails-dom-testing (~> 2.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) + rack-session (>= 1.0.1) rack-test (>= 0.6.3) - rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (7.0.8) - actionpack (= 7.0.8) - activerecord (= 7.0.8) - activestorage (= 7.0.8) - activesupport (= 7.0.8) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + 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.0.8) - activesupport (= 7.0.8) + actionview (7.1.3.4) + activesupport (= 7.1.3.4) builder (~> 3.1) - erubi (~> 1.4) - rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.1, >= 1.2.0) - activejob (7.0.8) - activesupport (= 7.0.8) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activejob (7.1.3.4) + activesupport (= 7.1.3.4) globalid (>= 0.3.6) - activejob-status (1.0.0) + activejob-status (1.0.1) activejob (>= 6.0) activesupport (>= 6.0) - activemodel (7.0.8) - activesupport (= 7.0.8) + activemodel (7.1.3.4) + activesupport (= 7.1.3.4) activemodel-serializers-xml (1.0.2) activemodel (> 5.x) activesupport (> 5.x) builder (~> 3.1) - activerecord (7.0.8) - activemodel (= 7.0.8) - activesupport (= 7.0.8) + activerecord (7.1.3.4) + activemodel (= 7.1.3.4) + activesupport (= 7.1.3.4) + timeout (>= 0.4.0) activerecord-session_store (2.1.0) actionpack (>= 6.1) activerecord (>= 6.1) @@ -70,77 +75,81 @@ GEM multi_json (~> 1.11, >= 1.11.2) rack (>= 2.0.8, < 4) railties (>= 6.1) - activestorage (7.0.8) - actionpack (= 7.0.8) - activejob (= 7.0.8) - activerecord (= 7.0.8) - activesupport (= 7.0.8) + 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) - mini_mime (>= 1.1.0) - activesupport (7.0.8) + activesupport (7.1.3.4) + base64 + bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) + connection_pool (>= 2.2.5) + drb i18n (>= 1.6, < 2) minitest (>= 5.1) + mutex_m tzinfo (~> 2.0) - addressable (2.8.5) + addressable (2.8.6) public_suffix (>= 2.0.2, < 6.0) ast (2.4.2) autoprefixer-rails (10.4.16.0) execjs (~> 2) awesome_print (1.9.2) + base64 (0.2.0) better_errors (2.10.1) erubi (>= 1.0.0) rack (>= 0.9.0) rouge (>= 1.0.0) - better_html (2.0.2) - actionview (>= 6.0) - activesupport (>= 6.0) - ast (~> 2.0) - erubi (~> 1.4) - parser (>= 2.4) - smart_properties - binding_of_caller (1.0.0) - debug_inspector (>= 0.0.1) - bootsnap (1.17.0) + bigdecimal (3.1.8) + binding_of_caller (1.0.1) + debug_inspector (>= 1.2.0) + bootsnap (1.18.3) msgpack (~> 1.2) - brakeman (6.0.1) + brakeman (6.1.2) + racc browser (5.3.1) - builder (3.2.4) - bullet (7.1.4) + builder (3.3.0) + bullet (7.2.0) activesupport (>= 3.0.0) uniform_notifier (~> 1.11) - capybara (3.39.2) + capybara (3.40.0) addressable matrix mini_mime (>= 0.1.3) - nokogiri (~> 1.8) + nokogiri (~> 1.11) rack (>= 1.6.0) rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) cgi (0.3.6) chunky_png (1.4.0) - combine_pdf (1.0.24) + combine_pdf (1.0.26) matrix ruby-rc4 (>= 0.1.5) - concurrent-ruby (1.2.2) - config (5.0.0) + concurrent-ruby (1.3.3) + config (5.5.1) deep_merge (~> 1.2, >= 1.2.1) - dry-validation (~> 1.0, >= 1.0.0) + connection_pool (2.4.1) cookies_eu (1.7.8) js_cookie_rails (~> 2.2.0) - crack (0.4.5) + crack (1.0.0) + bigdecimal rexml crass (1.0.6) + cssbundling-rails (1.4.1) + railties (>= 6.0.0) date (3.3.4) - debug (1.8.0) - irb (>= 1.5.0) - reline (>= 0.3.1) - debug_inspector (1.0.0) + debug (1.9.2) + irb (~> 1.10) + reline (>= 0.3.8) + debug_inspector (1.2.0) deep_merge (1.2.2) descriptive_statistics (2.5.1) - diff-lcs (1.5.0) + diff-lcs (1.5.1) docile (1.4.0) + drb (2.2.1) dry-configurable (1.1.0) dry-core (~> 1.0, < 2) zeitwerk (~> 2.6) @@ -173,21 +182,21 @@ GEM dry-initializer (~> 3.0) dry-schema (>= 1.12, < 2) zeitwerk (~> 2.6) - erubi (1.12.0) + erubi (1.13.0) et-orbi (1.2.7) tzinfo exception_notification (4.5.0) actionmailer (>= 5.2, < 8) activesupport (>= 5.2, < 8) execjs (2.9.1) - factory_bot (6.4.2) + factory_bot (6.4.5) activesupport (>= 5.0.0) - factory_bot_rails (6.4.2) + factory_bot_rails (6.4.3) factory_bot (~> 6.4) railties (>= 5.0.0) - faker (3.2.2) + faker (3.4.2) i18n (>= 1.8.11, < 2) - ffi (1.15.5) + ffi (1.16.3) fugit (1.9.0) et-orbi (~> 1, >= 1.2.7) raabro (~> 1.4) @@ -197,18 +206,17 @@ GEM glob (0.4.0) globalid (1.2.1) activesupport (>= 6.1) - hashdiff (1.0.1) - highline (2.1.0) + hashdiff (1.1.0) + highline (3.0.1) histogram (0.2.4.1) - i18n (1.14.1) + i18n (1.14.5) concurrent-ruby (~> 1.0) i18n-js (4.2.3) glob (>= 0.4.0) i18n - i18n-tasks (1.0.13) + i18n-tasks (1.0.14) activesupport (>= 4.0.2) ast (>= 2.1.0) - better_html (>= 1.0, < 3.0) erubi highline (>= 2.0.0) i18n @@ -216,21 +224,24 @@ GEM rails-i18n rainbow (>= 2.2.2, < 4.0) terminal-table (>= 1.5.1) - io-console (0.6.0) - irb (1.7.4) - reline (>= 0.3.6) + io-console (0.7.2) + irb (1.14.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) js-routes (2.2.8) railties (>= 4) js_cookie_rails (2.2.0) railties (>= 3.1) - jsbundling-rails (1.2.1) + jsbundling-rails (1.3.1) railties (>= 6.0.0) - json (2.7.0) - jwt (2.7.1) + json (2.7.2) + jwt (2.8.2) + base64 kgio (2.11.4) - listen (3.8.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) @@ -240,71 +251,80 @@ GEM net-imap net-pop net-smtp - marcel (1.0.2) + marcel (1.0.4) matrix (0.4.2) - method_source (1.0.0) mini_mime (1.1.5) - mini_portile2 (2.8.6) - minitest (5.20.0) + mini_portile2 (2.8.7) + minitest (5.24.1) mono_logger (1.1.2) msgpack (1.7.2) multi_json (1.15.0) mustermann (3.0.0) ruby2_keywords (~> 0.0.1) - net-imap (0.4.7) + mutex_m (0.2.0) + net-imap (0.4.12) date net-protocol net-pop (0.1.2) net-protocol net-protocol (0.2.2) timeout - net-smtp (0.4.0) + net-smtp (0.5.0) net-protocol - nio4r (2.7.0) - nokogiri (1.16.5) + nio4r (2.7.3) + nokogiri (1.16.7) mini_portile2 (~> 2.8.2) racc (~> 1.4) - parser (3.2.2.4) + observer (0.1.2) + parser (3.3.2.0) ast (~> 2.4.1) racc - pdf-core (0.9.0) - pg (1.5.4) - pkg-config (1.5.5) + pdf-core (0.10.0) + pg (1.5.7) + pkg-config (1.5.6) pluck_to_hash (1.0.2) activerecord (>= 4.0.2) activesupport (>= 4.0.2) - prawn (2.4.0) - pdf-core (~> 0.9.0) - ttfunk (~> 1.7) + prawn (2.5.0) + matrix (~> 0.4) + pdf-core (~> 0.10.0) + ttfunk (~> 1.8) prawn-qrcode (0.5.2) prawn (>= 1) rqrcode (>= 1.0.0) - public_suffix (5.0.3) + psych (5.1.2) + stringio + public_suffix (5.0.5) puma (6.4.2) nio4r (~> 2.0) raabro (1.4.0) - racc (1.7.3) - rack (2.2.8.1) + racc (1.8.1) + rack (2.2.9) rack-cors (2.0.2) rack (>= 2.0.0) rack-protection (3.1.0) rack (~> 2.2, >= 2.2.4) + rack-session (1.0.2) + rack (< 3) rack-test (2.1.0) rack (>= 1.3) - rails (7.0.8) - actioncable (= 7.0.8) - actionmailbox (= 7.0.8) - actionmailer (= 7.0.8) - actionpack (= 7.0.8) - actiontext (= 7.0.8) - actionview (= 7.0.8) - activejob (= 7.0.8) - activemodel (= 7.0.8) - activerecord (= 7.0.8) - activestorage (= 7.0.8) - activesupport (= 7.0.8) + rackup (1.0.0) + rack (< 3) + webrick + 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.0.8) + railties (= 7.1.3.4) rails-controller-testing (1.0.5) actionpack (>= 5.0.1.rc1) actionview (>= 5.0.1.rc1) @@ -319,30 +339,33 @@ GEM rails-i18n (7.0.3) i18n (>= 0.7, < 2) railties (>= 6.0.0, < 8) - rails_performance (1.2.1) + rails_performance (1.2.2) browser railties redis redis-namespace - railties (7.0.8) - actionpack (= 7.0.8) - activesupport (= 7.0.8) - method_source + railties (7.1.3.4) + actionpack (= 7.1.3.4) + activesupport (= 7.1.3.4) + irb + rackup (>= 1.0.0) rake (>= 12.2) - thor (~> 1.0) - zeitwerk (~> 2.5) + thor (~> 1.0, >= 1.2.2) + zeitwerk (~> 2.6) rainbow (3.1.1) raindrops (0.20.0) - rake (13.1.0) + rake (13.2.1) rb-fsevent (0.11.2) rb-inotify (0.10.1) ffi (~> 1.0) + rdoc (6.7.0) + psych (>= 4.0.0) redcarpet (3.6.0) redis (4.8.1) redis-namespace (1.11.0) redis (>= 4) - regexp_parser (2.8.1) - reline (0.3.7) + regexp_parser (2.9.0) + reline (0.5.9) io-console (~> 0.5) responders (3.1.1) actionpack (>= 5.2) @@ -357,33 +380,35 @@ GEM redis (>= 3.3) resque (>= 1.27) rufus-scheduler (~> 3.2, != 3.3) - rexml (3.2.6) - rmagick (5.3.0) + rexml (3.3.4) + strscan + rmagick (6.0.1) + observer (~> 0.1) pkg-config (~> 1.4) rouge (4.1.3) rqrcode (2.2.0) chunky_png (~> 1.0) rqrcode_core (~> 1.0) rqrcode_core (1.2.0) - rspec-core (3.12.2) - rspec-support (~> 3.12.0) - rspec-expectations (3.12.3) + rspec-core (3.13.0) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.1) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.12.0) - rspec-mocks (3.12.6) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.1) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.12.0) - rspec-rails (6.1.0) + rspec-support (~> 3.13.0) + rspec-rails (6.1.3) actionpack (>= 6.1) activesupport (>= 6.1) railties (>= 6.1) - rspec-core (~> 3.12) - rspec-expectations (~> 3.12) - rspec-mocks (~> 3.12) - rspec-support (~> 3.12) - rspec-support (3.12.1) + rspec-core (~> 3.13) + rspec-expectations (~> 3.13) + rspec-mocks (~> 3.13) + rspec-support (~> 3.13) + rspec-support (3.13.1) rtesseract (3.1.3) - ruby-next-core (0.15.3) + ruby-next-core (1.0.3) ruby-progressbar (1.13.0) ruby-rc4 (0.1.5) ruby2_keywords (0.0.5) @@ -391,17 +416,9 @@ GEM rufus-scheduler (3.9.1) fugit (~> 1.1, >= 1.1.6) rugged (1.7.2) - sass-rails (6.0.0) - sassc-rails (~> 2.1, >= 2.1.1) - sassc (2.4.0) - ffi (~> 1.9) - sassc-rails (2.1.2) - railties (>= 4.0.0) - sassc (>= 2.0) - sprockets (> 3.0) - sprockets-rails - tilt - selenium-webdriver (4.15.0) + selenium-webdriver (4.23.0) + base64 (~> 0.2) + logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 3.0) websocket (~> 1.0) @@ -425,23 +442,25 @@ GEM rack (~> 2.2, >= 2.2.4) rack-protection (= 3.1.0) tilt (~> 2.0) - smart_properties (1.17.0) sprockets (4.2.1) concurrent-ruby (~> 1.0) rack (>= 2.2.4, < 4) - 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) + stringio (3.1.1) + strscan (3.1.0) terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) - terser (1.1.20) + terser (1.2.3) execjs (>= 0.3.0, < 3) - thor (1.3.0) + thor (1.3.1) tilt (2.3.0) time-warp (1.0.15) timeout (0.4.1) - ttfunk (1.7.0) + ttfunk (1.8.0) + bigdecimal (~> 3.1) tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode-display_width (2.5.0) @@ -449,17 +468,18 @@ GEM kgio (~> 2.6) raindrops (~> 0.7) uniform_notifier (1.16.0) - webmock (3.19.1) + webmock (3.23.1) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - websocket (1.2.10) + webrick (1.8.1) + websocket (1.2.11) websocket-driver (0.7.6) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.6.12) + zeitwerk (2.6.17) PLATFORMS ruby @@ -480,8 +500,10 @@ DEPENDENCIES combine_pdf config cookies_eu + cssbundling-rails (~> 1.4) debug (>= 1.0.0) descriptive_statistics + dry-validation exception_notification factory_bot_rails faker @@ -504,7 +526,7 @@ DEPENDENCIES prawn-qrcode puma rack-cors - rails (~> 7.0.8) + rails (~> 7.1.3) rails-controller-testing rails-html-sanitizer rails-i18n (~> 7.0.0) @@ -514,12 +536,11 @@ DEPENDENCIES responders resque resque-scheduler - rmagick (~> 5.3.0) - rspec-rails (~> 6.1.0) + rmagick (~> 6.0.1) + rspec-rails (~> 6.1.3) rtesseract rubyzip rugged - sass-rails selenium-webdriver shoulda shoulda-callback-matchers (~> 1.1.1) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000000..f367cf2e7e --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,28 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +| ------- | ------------------ | +| 2.4.x | :white_check_mark: | +| < 2.4.0 | :x: | + +## Reporting a Vulnerability + +To report a security vulnerability, please email [markus-security@cs.toronto.edu](mailto:markus-security@cs.toronto.edu). + +**Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.** + +Please include as much of the information listed below as you can to help us better understand and resolve the issue: + +- The type of issue (e.g., buffer overflow, SQL injection, or cross-site scripting) +- Full paths of source file(s) related to the manifestation of the issue +- The location of the affected source code (tag/branch/commit or direct URL) +- Any special configuration required to reproduce the issue +- Step-by-step instructions to reproduce the issue +- Proof-of-concept or exploit code (if possible) +- Impact of the issue, including how an attacker might exploit the issue + +This information will help us triage your report more quickly. + +_[The above is based on https://github.com/github/platform-samples/security/policy.]_ diff --git a/app/MARKUS_VERSION b/app/MARKUS_VERSION index de9aab602b..30acdc6b3c 100644 --- a/app/MARKUS_VERSION +++ b/app/MARKUS_VERSION @@ -1 +1 @@ -VERSION=v2.4.12,PATCH_LEVEL=DEV +VERSION=v2.5.0,PATCH_LEVEL=DEV diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js index ce7664197a..cea21fcdc3 100644 --- a/app/assets/config/manifest.js +++ b/app/assets/config/manifest.js @@ -1,14 +1,9 @@ //= link_tree ../javascripts .js -//= link_directory ../stylesheets .css -//= link common/SyntaxHighlighter.css -//= link common/jupyterlab-markus-custom.css //= link_tree ../../../vendor/assets/javascripts .js -//= link_tree ../../../vendor/assets/stylesheets .css //= link cookies_eu.js // Images and fonts so that views can link to them // //= link_tree ../images //= link_tree ../builds -//= link flatpickr/dist/flatpickr.css diff --git a/app/assets/javascripts/Annotations/annotation_text_displayer.js b/app/assets/javascripts/Annotations/annotation_text_displayer.js index af9d4d41e0..785987ec64 100644 --- a/app/assets/javascripts/Annotations/annotation_text_displayer.js +++ b/app/assets/javascripts/Annotations/annotation_text_displayer.js @@ -28,7 +28,7 @@ class AnnotationTextDisplayer { // Show the displayer show() { this.display_node.style.display = "block"; - MathJax.Hub.Queue(["Typeset", MathJax.Hub, this.display_node]); + MathJax.typeset([this.display_node]); } // Set the parent element of the display node diff --git a/app/assets/javascripts/Annotations/text_annotation_manager.js b/app/assets/javascripts/Annotations/text_annotation_manager.js index e7ce8b0136..2a5dc6101d 100644 --- a/app/assets/javascripts/Annotations/text_annotation_manager.js +++ b/app/assets/javascripts/Annotations/text_annotation_manager.js @@ -1,6 +1,6 @@ /** * AnnotationManager subclass for plaintext files. Its constructor is given a list of DOM elements - * (one for each line of text in the file), that have been transformed through SyntaxHighlighter. + * (one for each line of text in the file), that have been transformed through syntax highlighting. */ class TextAnnotationManager extends AnnotationManager { constructor(source_nodes) { @@ -120,7 +120,7 @@ class TextAnnotationManager extends AnnotationManager { ); // If the entire line was selected through a triple-click, highlight the entire line. - if (mouse_anchor.nodeName === "LI" && mouse_focus.nodeName === "LI") { + if (mouse_anchor === mouse_focus && this.isSourceLineElement(mouse_focus)) { return { line_start: line_start, line_end: line_end, @@ -130,10 +130,10 @@ class TextAnnotationManager extends AnnotationManager { } // If we selected an entire line the above returns + 1, a fix follows - if (mouseSelection.anchorNode.nodeName === "LI") { + if (this.isSourceLineElement(mouseSelection.anchorNode)) { line_start--; } - if (mouseSelection.focusNode.nodeName === "LI") { + if (this.isSourceLineElement(mouseSelection.focusNode)) { line_end--; } @@ -213,13 +213,18 @@ class TextAnnotationManager extends AnnotationManager { }; } - // Given some node, traverses upwards until it finds the LI element that represents a line of code in SyntaxHighlighter. + // Given some node, traverses upwards until it finds the span element that represents a line of code. // This is useful for figuring out what text is currently selected, using window.getSelection().anchorNode / focusNode getRootFromSelection(node) { let current_node = node; - while (current_node !== null && current_node.tagName !== "LI") { + while (current_node !== null && !this.isSourceLineElement(current_node)) { current_node = current_node.parentNode; } return current_node; } + + // Returns whether node is an element representing a source line (after syntax highlighting has been applied). + isSourceLineElement(node) { + return node.tagName === "SPAN" && node.className.includes("source-line"); + } } diff --git a/app/assets/javascripts/Components/Helpers/range_selector.js b/app/assets/javascripts/Components/Helpers/range_selector.js index 4034f928ef..2a5d7d7bb3 100644 --- a/app/assets/javascripts/Components/Helpers/range_selector.js +++ b/app/assets/javascripts/Components/Helpers/range_selector.js @@ -93,10 +93,10 @@ function addMouseOverToNode(node, content) { content_container.innerHTML = safe_marked(content); content_container.className = "markus-annotation-content"; node.ownerDocument.body.appendChild(content_container); - // TODO: apply MathJax typesetting to the content_container node - // MathJax.Hub.Queue(["Typeset", MathJax.Hub, content_container]); // <- this works but mathjax css isn't applied - // // because iframe has its own css context node.addEventListener("mouseenter", e => { + // We make MathJax typesetting lazy to avoid rendering issues when editing an annotation + // from the "Annotations" tab, where the NotebookViewer component is updated before being visible. + node.ownerDocument.defaultView.MathJax.typeset([content_container]); let offset_height = 0; for (let elem of node.ownerDocument.getElementsByClassName("markus-annotation-content")) { if (getComputedStyle(elem).display !== "none") { @@ -112,7 +112,15 @@ function addMouseOverToNode(node, content) { }); } -export function markupTextInRange(range, content) { +export function markupTextInRange(range, content, id) { + let existing_node = range.startContainer.ownerDocument.getElementById(`markus-annotation-${id}`); + if (existing_node !== null) { + let existing_parent = existing_node.parentNode; + let replace_node = existing_node.cloneNode(true); // Create a deep copy + existing_parent.replaceChild(replace_node, existing_node); + addMouseOverToNode(replace_node, content); + return; + } if (range.startContainer === range.endContainer) { const old_node = range.startContainer; const parent = old_node.parentNode; @@ -120,6 +128,7 @@ export function markupTextInRange(range, content) { if (old_node.nodeType === Node.TEXT_NODE) { new_node = document.createElement("span"); new_node.className = "markus-annotation"; + new_node.id = `markus-annotation-${id}`; const unmarked1 = document.createTextNode(old_node.nodeValue.substring(0, range.startOffset)); const marked = document.createTextNode( old_node.nodeValue.substring(range.startOffset, range.endOffset) @@ -132,6 +141,7 @@ export function markupTextInRange(range, content) { } else if (old_node.nodeName === "img" || old_node.childNodes.length) { new_node = document.createElement("div"); new_node.className = "markus-annotation"; + new_node.id = `markus-annotation-${id}`; new_node.appendChild(old_node.cloneNode(true)); parent.replaceChild(new_node, old_node); } @@ -144,6 +154,7 @@ export function markupTextInRange(range, content) { if (old_node.nodeType === Node.TEXT_NODE) { new_node = document.createElement("span"); new_node.className = "markus-annotation"; + new_node.id = `markus-annotation-${id}`; if (old_node === range.startContainer) { const unmarked = document.createTextNode( old_node.nodeValue.substring(0, range.startOffset) @@ -167,6 +178,7 @@ export function markupTextInRange(range, content) { } else if (old_node.nodeName === "img" || old_node.childNodes.length) { new_node = document.createElement("div"); new_node.className = "markus-annotation"; + new_node.id = `markus-annotation-${id}`; new_node.appendChild(old_node.cloneNode(true)); parent.replaceChild(new_node, old_node); } diff --git a/app/assets/javascripts/Components/Modals/submission_file_upload_modal.jsx b/app/assets/javascripts/Components/Modals/submission_file_upload_modal.jsx index c3c886c85e..997c3ef2bf 100644 --- a/app/assets/javascripts/Components/Modals/submission_file_upload_modal.jsx +++ b/app/assets/javascripts/Components/Modals/submission_file_upload_modal.jsx @@ -17,7 +17,29 @@ class SubmissionFileUploadModal extends React.Component { onSubmit = event => { event.preventDefault(); - this.props.onSubmit(this.state.newFiles, undefined, this.state.unzip, this.state.renameTo); + if (this.state.newFiles.length === 1) { + const newFilename = this.state.renameTo; + const originalFilename = this.state.newFiles[0].name; // Assuming only one file is uploaded + // Check if newFilename is not blank + if (newFilename.trim() !== "") { + const originalExtension = originalFilename.split(".").pop(); + const newExtension = newFilename.split(".").pop(); + + if (originalExtension !== newExtension) { + const confirmChange = window.confirm(I18n.t("modals.file_upload.rename_warning")); + if (!confirmChange) { + // Prevent form submission if the user cancels the operation + return; + } + } + } + } + this.props.onSubmit( + this.state.newFiles, + undefined, + this.state.unzip, + this.state.renameTo.trim() + ); }; handleFileUpload = event => { @@ -139,6 +161,14 @@ class SubmissionFileUploadModal extends React.Component { {I18n.t("submissions.student.rename_file_to")}  {this.fileRenameInputBox()} + {this.props.progressVisible && ( + + )}
{ new DropDownMenu($(`#annotation_category_${cat.id}`), $(`#annotation_text_list_${cat.id}`)); + MathJax.typeset([`#annotation_text_list_${cat.id}`]); }); } @@ -37,7 +38,10 @@ export class AnnotationManager extends React.Component { onMouseDown={e => e.preventDefault()} title={text.content} > - {text.content.slice(0, 70)} + {!text.deduction ? "" : "-" + text.deduction} diff --git a/app/assets/javascripts/Components/Result/annotation_panel.jsx b/app/assets/javascripts/Components/Result/annotation_panel.jsx index 0742e4e94d..f5806b59ac 100644 --- a/app/assets/javascripts/Components/Result/annotation_panel.jsx +++ b/app/assets/javascripts/Components/Result/annotation_panel.jsx @@ -26,7 +26,7 @@ export class AnnotationPanel extends React.Component { if (this.props.released_to_students || this.props.remarkSubmitted) { let target_id = "overall_comment_text"; document.getElementById(target_id).innerHTML = safe_marked(this.state.overallComment); - MathJax.Hub.Queue(["Typeset", MathJax.Hub, target_id]); + MathJax.typeset([`#${target_id}`]); } } diff --git a/app/assets/javascripts/Components/Result/annotation_table.jsx b/app/assets/javascripts/Components/Result/annotation_table.jsx index 2bf6be67fe..f411bd18d2 100644 --- a/app/assets/javascripts/Components/Result/annotation_table.jsx +++ b/app/assets/javascripts/Components/Result/annotation_table.jsx @@ -162,11 +162,11 @@ export class AnnotationTable extends React.Component { }; componentDidMount() { - MathJax.Hub.Queue(["Typeset", MathJax.Hub, "annotation_table"]); + MathJax.typeset(["#annotation_table"]); } componentDidUpdate() { - MathJax.Hub.Queue(["Typeset", MathJax.Hub, "annotation_table"]); + MathJax.typeset(["#annotation_table"]); } render() { diff --git a/app/assets/javascripts/Components/Result/feedback_file_panel.jsx b/app/assets/javascripts/Components/Result/feedback_file_panel.jsx index 280b3b3d28..80c10944c9 100644 --- a/app/assets/javascripts/Components/Result/feedback_file_panel.jsx +++ b/app/assets/javascripts/Components/Result/feedback_file_panel.jsx @@ -1,6 +1,6 @@ import React from "react"; import {FileViewer} from "./file_viewer"; -import {getType} from "mime/lite"; +import mime from "mime/lite"; export class FeedbackFilePanel extends React.Component { constructor(props) { @@ -86,7 +86,7 @@ export class FeedbackFilePanel extends React.Component { submission_id={this.props.submission_id} selectedFile={file_obj.filename} selectedFileURL={url} - mime_type={getType(file_obj.filename)} + mime_type={mime.getType(file_obj.filename)} selectedFileType={file_obj.type} />
diff --git a/app/assets/javascripts/Components/Result/notebook_viewer.jsx b/app/assets/javascripts/Components/Result/notebook_viewer.jsx index 75b1d6f2cf..336e204f46 100644 --- a/app/assets/javascripts/Components/Result/notebook_viewer.jsx +++ b/app/assets/javascripts/Components/Result/notebook_viewer.jsx @@ -30,9 +30,15 @@ export class NotebookViewer extends React.PureComponent { const end_node = doc.evaluate(annotation.end_node, doc).iterateNext(); const newRange = doc.createRange(); try { - newRange.setStart(start_node, annotation.start_offset); - newRange.setEnd(end_node, annotation.end_offset); - markupTextInRange(newRange, annotation.content); + if (doc.getElementById(`markus-annotation-${annotation.id}`) === null) { + newRange.setStart(start_node, annotation.start_offset); + newRange.setEnd(end_node, annotation.end_offset); + } else { + // Dummy values for the range + newRange.setStart(start_node, 0); + newRange.setEnd(end_node, 0); + } + markupTextInRange(newRange, annotation.content, annotation.id); } catch (error) { console.error(error); } @@ -41,6 +47,12 @@ export class NotebookViewer extends React.PureComponent { componentDidUpdate(prevProps) { if (prevProps.annotations !== this.props.annotations) { + // If annotations have been deleted, reload the notebook. + // TODO: Remove this after implementing functionality to manually remove + // an annotation (likely in range_selector.js). + if (prevProps.annotations.length > this.props.annotations.length) { + this.iframe.current.contentWindow.location.reload(); + } this.renderAnnotations(); } } diff --git a/app/assets/javascripts/Components/Result/pdf_viewer.jsx b/app/assets/javascripts/Components/Result/pdf_viewer.jsx index 1591f933f7..b3ab471bd1 100644 --- a/app/assets/javascripts/Components/Result/pdf_viewer.jsx +++ b/app/assets/javascripts/Components/Result/pdf_viewer.jsx @@ -110,8 +110,13 @@ export class PDFViewer extends React.PureComponent { const cursor = this.props.released_to_students ? "default" : "crosshair"; const userSelect = this.props.released_to_students ? "default" : "none"; return ( -
-
+
+
{ let target_id = "remark-request-preview"; document.getElementById(target_id).innerHTML = safe_marked(this.state.value); - MathJax.Hub.Queue(["Typeset", MathJax.Hub, target_id]); + MathJax.typeset([`#${target_id}`]); }; updateValue = event => { diff --git a/app/assets/javascripts/Components/Result/result.jsx b/app/assets/javascripts/Components/Result/result.jsx index 7231112e8d..7cfa40d91e 100644 --- a/app/assets/javascripts/Components/Result/result.jsx +++ b/app/assets/javascripts/Components/Result/result.jsx @@ -1,6 +1,10 @@ import React from "react"; import {render} from "react-dom"; +// TODO: This import seems to be required to automatically include the X-CSRF-TOKEN header on +// jQuery AJAX requests in this component, unlike all other pages. Requires further investigation. +import "@rails/ujs"; + import {LeftPane} from "./left_pane"; import {RightPane} from "./right_pane"; import {SubmissionSelector} from "./submission_selector"; @@ -365,10 +369,9 @@ class Result extends React.Component { this.refreshAnnotationCategories(); } - if (typeof window.annotation_manager.hide_selection_box === "function") { + if (typeof window.annotation_manager?.hide_selection_box === "function") { window.annotation_manager.hide_selection_box(); } - reloadDOM(); }; addExistingAnnotation = annotation_text_id => { @@ -505,10 +508,9 @@ class Result extends React.Component { } this.update_annotation_text(annotation.annotation_text_id, annotation.content, annotation.id); - if (typeof window.annotation_manager.hide_selection_box === "function") { + if (typeof window.annotation_manager?.hide_selection_box === "function") { window.annotation_manager.hide_selection_box(); } - reloadDOM(); }; /** @@ -527,8 +529,11 @@ class Result extends React.Component { } destroyAnnotation(annotation_id, range, annotation_text_id) { - if (annotation_manager.annotation_text_manager.annotationTextExists(annotation_text_id)) { - annotation_manager.removeAnnotation(annotation_id); + if ( + !!window.annotation_manager && + window.annotation_manager.annotation_text_manager.annotationTextExists(annotation_text_id) + ) { + window.annotation_manager.removeAnnotation(annotation_id); } let newAnnotations = [...this.state.annotations]; const i = newAnnotations.findIndex(a => a.id === annotation_id); @@ -537,10 +542,9 @@ class Result extends React.Component { this.setState({annotations: newAnnotations}); } - if (typeof annotation_manager.hide_selection_box === "function") { - annotation_manager.hide_selection_box(); + if (typeof window.annotation_manager?.hide_selection_box === "function") { + window.annotation_manager.hide_selection_box(); } - reloadDOM(); // Need to remove data attribute from highlight elements - must be last. $("span").removeAttr(`data-annotationid${annotation_id}`); } diff --git a/app/assets/javascripts/Components/Result/submission_file_panel.jsx b/app/assets/javascripts/Components/Result/submission_file_panel.jsx index c264c5acf9..3ac61c136d 100644 --- a/app/assets/javascripts/Components/Result/submission_file_panel.jsx +++ b/app/assets/javascripts/Components/Result/submission_file_panel.jsx @@ -4,7 +4,7 @@ import ReactDOM from "react-dom"; import {AnnotationManager} from "./annotation_manager"; import {FileViewer} from "./file_viewer"; import {DownloadSubmissionModal} from "./download_submission_modal"; -import {getType} from "mime/lite"; +import mime from "mime/lite"; export class SubmissionFilePanel extends React.Component { constructor(props) { @@ -157,7 +157,7 @@ export class SubmissionFilePanel extends React.Component { submission_file_mime_type = null; } else { submission_file_id = this.state.selectedFile[1]; - submission_file_mime_type = getType(this.state.selectedFile[0]); + submission_file_mime_type = mime.getType(this.state.selectedFile[0]); } return ( diff --git a/app/assets/javascripts/Components/Result/text_viewer.jsx b/app/assets/javascripts/Components/Result/text_viewer.jsx index a3962c63c0..eadb5306a0 100644 --- a/app/assets/javascripts/Components/Result/text_viewer.jsx +++ b/app/assets/javascripts/Components/Result/text_viewer.jsx @@ -1,5 +1,6 @@ import React from "react"; import {render} from "react-dom"; +import Prism from "prismjs"; export class TextViewer extends React.PureComponent { constructor(props) { @@ -14,6 +15,7 @@ export class TextViewer extends React.PureComponent { } componentDidMount() { + this.highlight_root = this.raw_content.current.parentNode; if (this.props.content) { this.ready_annotations(); } @@ -42,29 +44,18 @@ export class TextViewer extends React.PureComponent { * 3. Scroll to line numbered this.props.focusLine */ ready_annotations = () => { - if (this.highlight_root !== null) { - this.highlight_root.remove(); - } + this.run_syntax_highlighting(); + if (this.annotation_manager !== null) { this.annotation_manager.annotation_text_displayer.hide(); } - const preElementName = this.raw_content.current.getAttribute("name"); - dp.SyntaxHighlighter.HighlightAll( - preElementName, - true /* showGutter */, - false /* showControls */ - ); - this.highlight_root = - this.raw_content.current.parentNode.getElementsByClassName("dp-highlighter")[0]; this.highlight_root.style.font_size = this.state.fontSize + "em"; if (this.props.resultView) { window.annotation_type = ANNOTATION_TYPES.CODE; - window.annotation_manager = new TextAnnotationManager( - this.highlight_root.children[1].children - ); + window.annotation_manager = new TextAnnotationManager(this.raw_content.current.children); this.annotation_manager = window.annotation_manager; } @@ -72,6 +63,69 @@ export class TextViewer extends React.PureComponent { this.scrollToLine(this.props.focusLine); }; + run_syntax_highlighting = () => { + Prism.highlightElement(this.raw_content.current, false); + let nodeLines = []; + let currLine = document.createElement("span"); + currLine.classList.add("source-line"); + for (let node of this.raw_content.current.childNodes) { + if (node.nodeType === Node.TEXT_NODE) { + // SourceCodeLine.glow assumes text nodes are wrapped in elements + let textContainer = document.createElement("span"); + let textNode = null; + if (node.textContent.includes("\n")) { + const splits = node.textContent.split("\n"); + for (let i = 0; i < splits.length - 1; i++) { + textNode = document.createTextNode(splits[i] + "\n"); + textContainer.appendChild(textNode); + currLine.appendChild(textContainer); + nodeLines.push(currLine); + currLine = document.createElement("span"); + currLine.classList.add("source-line"); + textContainer = document.createElement("span"); + } + textNode = document.createTextNode(splits[splits.length - 1]); + } else { + textNode = node.cloneNode(true); + } + textContainer.appendChild(textNode); + currLine.appendChild(textContainer); + } else { + if (node.textContent.includes("\n")) { + const splits = node.textContent.split("\n"); + let textContainer = document.createElement("span"); + textContainer.className = node.className; + let textNode = null; + for (let i = 0; i < splits.length - 1; i++) { + textNode = document.createTextNode(splits[i] + "\n"); + textContainer.appendChild(textNode); + currLine.appendChild(textContainer); + nodeLines.push(currLine); + currLine = document.createElement("span"); + currLine.classList.add("source-line"); + textContainer = document.createElement("span"); + textContainer.className = node.className; + } + textNode = document.createTextNode(splits[splits.length - 1]); + textContainer.appendChild(textNode); + currLine.appendChild(textContainer); + } else { + currLine.appendChild(node.cloneNode(true)); + } + } + } + if (currLine.textContent.length > 0) { + nodeLines.appendChild(currLine); + } + nodeLines.push(this.raw_content.current.lastChild.cloneNode(true)); + while (this.raw_content.current.firstChild) { + this.raw_content.current.removeChild(this.raw_content.current.lastChild); + } + for (let n of nodeLines) { + this.raw_content.current.appendChild(n); + } + }; + change_font_size = delta => { this.setState({font_size: Math.max(this.state.font_size + delta, 0.25)}); }; @@ -109,16 +163,12 @@ export class TextViewer extends React.PureComponent { return; } - const line = this.highlight_root.querySelector(`li:nth-of-type(${lineNumber})`); + const line = this.highlight_root.querySelector(`span.source-line:nth-of-type(${lineNumber})`); if (line) { line.scrollIntoView(); } }; - componentWillUnmount() { - document.querySelectorAll(".dp-highlighter").forEach(node => node.remove()); - } - copyToClipboard = () => { navigator.clipboard.writeText(this.props.content).then(() => { this.setState({copy_success: true}); @@ -142,13 +192,12 @@ export class TextViewer extends React.PureComponent { this.change_font_size(-0.25)}> -A - dp.sh.Toolbar.Command("About", this.highlight_root)}> - ? -
-
-          {this.props.content}
+        
+          
+            {this.props.content}
+          
         
); diff --git a/app/assets/javascripts/Components/__tests__/assignment_summary.test.jsx b/app/assets/javascripts/Components/__tests__/assignment_summary.test.jsx new file mode 100644 index 0000000000..fd6af28c48 --- /dev/null +++ b/app/assets/javascripts/Components/__tests__/assignment_summary.test.jsx @@ -0,0 +1,100 @@ +/*** + * Tests for AssignmentSummaryTable Component + */ + +import {AssignmentSummaryTable} from "../assignment_summary_table"; +import {render, screen, fireEvent, cleanup, waitFor} from "@testing-library/react"; + +describe("For the AssignmentSummaryTable's display of inactive groups", () => { + let groups_sample; + beforeEach(async () => { + groups_sample = [ + { + group_name: "group_0001", + section: null, + members: [ + ["c9nielse", "Nielsen", "Carl", true], + ["c8szyman", "Szymanowski", "Karol", true], + ], + tags: [], + graders: [["c9varoqu", "Nelle", "Varoquaux"]], + marking_state: "released", + final_grade: 9.0, + criteria: {1: 0.0}, + max_mark: "21.0", + result_id: 15, + submission_id: 15, + total_extra_marks: null, + }, + { + group_name: "group_0002", + section: "LEC0101", + members: [ + ["c8debuss", "Debussy", "Claude", false], + ["c8holstg", "Holst", "Gustav", false], + ], + tags: [], + graders: [["c9varoqu", "Nelle", "Varoquaux"]], + marking_state: "released", + final_grade: 6.0, + criteria: {1: 2.0}, + max_mark: "21.0", + result_id: 5, + submission_id: 5, + total_extra_marks: null, + }, + ]; + fetch.mockReset(); + fetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + data: groups_sample, + criteriaColumns: [ + { + Header: "dolores", + accessor: "criteria.1", + className: "number", + headerClassName: "", + }, + ], + numAssigned: 2, + numMarked: 2, + ltiDeployments: [], + }), + }); + + render( + + ); + }); + + it("contains the correct amount of inactive groups in the hidden tooltip", () => { + expect(screen.getByTestId("show_inactive_groups_tooltip").getAttribute("title")).toEqual( + "1 inactive group" + ); + }); + + it("initially contains the active group", () => { + expect(screen.queryByText(/group_0002/)).toBeInTheDocument(); + }); + + it("initially does not contain the inactive group", () => { + expect(screen.queryByText(/group_0001/)).not.toBeInTheDocument(); + }); + + it("contains the inactive group after a single toggle", () => { + fireEvent.click(screen.getByTestId("show_inactive_groups")); + expect(screen.queryByText(/group_0001/)).toBeInTheDocument(); + }); + + it("doesn't contain the inactive group after two toggles", () => { + fireEvent.click(screen.getByTestId("show_inactive_groups")); + fireEvent.click(screen.getByTestId("show_inactive_groups")); + expect(screen.queryByText(/group_0001/)).not.toBeInTheDocument(); + }); +}); diff --git a/app/assets/javascripts/Components/__tests__/starter_file_manager.test.jsx b/app/assets/javascripts/Components/__tests__/starter_file_manager.test.jsx new file mode 100644 index 0000000000..7369a0bbcc --- /dev/null +++ b/app/assets/javascripts/Components/__tests__/starter_file_manager.test.jsx @@ -0,0 +1,136 @@ +import {StarterFileManager} from "../starter_file_manager"; +import {render, screen} from "@testing-library/react"; + +import React from "react"; + +jest.mock("@fortawesome/react-fontawesome", () => ({ + FontAwesomeIcon: () => { + return null; + }, +})); + +// workaround needed for using i18n in jest tests, see +// https://github.com/fnando/i18n/issues/26#issuecomment-1235751777 +jest.mock("i18n-js", () => { + return jest.requireActual("i18n-js/dist/require/index"); +}); + +[true, false].forEach(readOnly => { + let container; + describe(`When a user ${ + readOnly ? "without" : "with" + } assignment manage access visits the changes the StarterFileManager`, () => { + const pageInfo = { + files: [ + { + id: 1, + name: "New Starter File Group", + entry_rename: "", + use_rename: false, + files: [ + { + key: "a4.pdf", + size: 1, + submitted_date: "Thursday, March 14, 2024, 06:34:51 PM EDT", + url: "http://localhost:3000/csc108/courses/1/starter_file_groups/1/download_file?file_name=a4.pdf", + }, + ], + }, + ], + sections: [ + {section_id: 1, section_name: "LEC0101", group_id: 1, group_name: "New Starter File Group"}, + {section_id: 2, section_name: "LEC0201", group_id: 1, group_name: "New Starter File Group"}, + ], + available_after_due: true, + starterfileType: "sections", + defaultStarterFileGroup: 1, + }; + + // for the case where this is read only, we don't want to have the form as + // changed because that's impossible + // + // for the case where this is NOT read only, we want to simulate page changed + // so submit button is enabled (the point is to ensure this is possible) + pageInfo.form_changed = !readOnly; + + beforeEach(() => { + fetch.resetMocks(); + fetch.mockResponseOnce(JSON.stringify(pageInfo)); + + container = render( + + ); + }); + + it(`all buttons on the page are ${readOnly ? "disabled" : "enabled"}`, () => { + const buttons = screen.getAllByRole("button", { + name: new RegExp(`${I18n.t("assignments.starter_file.aria_labels.action_button")}`), + }); + + // one delete button per starter file group, one add button on top of that + expect(buttons).toHaveLength(pageInfo.files.length + 1); + + buttons.forEach(button => { + if (readOnly) { + expect(button).toBeDisabled(); + } else { + expect(button).not.toBeDisabled(); + } + }); + }); + + it(`all radio buttons are ${readOnly ? "disabled" : "enabled"}`, () => { + const radioButtons = screen.getAllByRole("radio"); + + // there's exactly 4 at all times + expect(radioButtons).toHaveLength(4); + + radioButtons.forEach(radioButton => { + if (readOnly) { + expect(radioButton).toBeDisabled(); + } else { + expect(radioButton).not.toBeDisabled(); + } + }); + }); + + it(`all dropdowns on the page are ${readOnly ? "disabled" : "enabled"}`, () => { + const dropdowns = screen.getAllByRole("combobox", { + name: new RegExp(`${I18n.t("assignments.starter_file.aria_labels.dropdown")}`), + }); + + // one for each section, plus one for the default starter file group + expect(dropdowns).toHaveLength(pageInfo.sections.length + 1); + + dropdowns.forEach(dropdown => { + if (readOnly) { + expect(dropdown).toBeDisabled(); + } else { + expect(dropdown).not.toBeDisabled(); + } + }); + }); + + it(`the checkbox on the page is ${readOnly ? "disabled" : "enabled"}`, () => { + // should only be one + const checkbox = screen.getByTestId("available_after_due_checkbox"); + + if (readOnly) { + expect(checkbox).toBeDisabled(); + } else { + expect(checkbox).not.toBeDisabled(); + } + }); + + it(`the submit button on the page is ${readOnly ? "disabled" : "enabled"}`, () => { + // should only be one + const saveButton = screen.getByTestId("save_button"); + + if (readOnly) { + expect(saveButton).toBeDisabled(); + } else { + expect(saveButton).not.toBeDisabled(); + } + }); + }); +}); diff --git a/app/assets/javascripts/Components/__tests__/submission_file_manager.test.jsx b/app/assets/javascripts/Components/__tests__/submission_file_manager.test.jsx index 5086e3838a..4cf9c594c1 100644 --- a/app/assets/javascripts/Components/__tests__/submission_file_manager.test.jsx +++ b/app/assets/javascripts/Components/__tests__/submission_file_manager.test.jsx @@ -2,7 +2,7 @@ import {SubmissionFileManager} from "../submission_file_manager"; import {mount} from "enzyme"; -describe("For the submissions managed by SubmissionFileManager's FileManager child component", () => { +describe("For the SubmissionFileManager", () => { let wrapper, rows; const files_sample = { entries: [ @@ -53,38 +53,148 @@ describe("For the submissions managed by SubmissionFileManager's FileManager chi document.body.innerHTML = `
`; }); - it("clicking on the row of each file opens up the preview for that file", () => { - wrapper.update(); - rows = wrapper.find(".file"); - expect(rows.length).toEqual(files_sample.entries.length); + describe("For the submissions managed by its FileManager child component", () => { + it("clicking on the row of each file opens up the preview for that file", () => { + wrapper.update(); + rows = wrapper.find(".file"); + expect(rows.length).toEqual(files_sample.entries.length); + + rows.forEach(row => { + row.simulate("click"); + wrapper.update(); + + // Locate the preview block + const file_viewer_comp = wrapper.find("FileViewer"); + expect(file_viewer_comp).toBeTruthy(); + }); + }); - rows.forEach(row => { - row.simulate("click"); + it("the preview opened is called with the correct props", () => { wrapper.update(); + rows = wrapper.find(".file"); + expect(rows.length).toEqual(files_sample.entries.length); + + rows.forEach(row => { + row.simulate("click"); + wrapper.update(); + + // Locate the preview block + const file_viewer_comp = wrapper.find("FileViewer"); + const file_displayed = files_sample.entries.find( + file => file.url === file_viewer_comp.props().selectedFileURL + ); - // Locate the preview block - const file_viewer_comp = wrapper.find("FileViewer"); - expect(file_viewer_comp).toBeTruthy(); + expect(file_viewer_comp.props().selectedFile).toEqual(file_displayed.relativeKey); + expect(file_viewer_comp.props().selectedFileType).toEqual(file_displayed.type); + }); }); }); - it("the preview opened is called with the correct props", () => { - wrapper.update(); - rows = wrapper.find(".file"); - expect(rows.length).toEqual(files_sample.entries.length); + describe("For the upload modal's progress bar", () => { + let file, mockXHR; + beforeEach(() => { + // a dummy file + file = new File(["content"], "test.txt", {type: "text/plain"}); - rows.forEach(row => { - row.simulate("click"); - wrapper.update(); + // Mock the XMLHttpRequest object + mockXHR = { + upload: { + addEventListener: jest.fn((event, callback) => { + if (event === "progress") { + mockXHR.upload.onprogress = callback; + } + }), + }, + }; + + // Override XMLHttpRequest constructor to return our mock request instead + jest.spyOn(window, "XMLHttpRequest").mockImplementation(() => mockXHR); + + // mock ajax post call to prevent request from being sent + $.post = jest.fn().mockImplementation(({xhr}) => { + // call mock xhr send function to trigger onprogress handler + xhr().send(); + + // do nothing for any chain action after $.post() itself + return { + then: jest.fn().mockReturnThis(), + fail: jest.fn().mockReturnThis(), + always: jest.fn().mockReturnThis(), + }; + }); + }); + + it("when 10% of file uploaded, bar is visible and has value of 10.0", () => { + // override our mock XMLHttpRequest's send method to send an onprogress + // event with 10% completion + mockXHR.send = jest.fn().mockImplementation(() => { + mockXHR.upload.onprogress({ + lengthComputable: true, + loaded: 1, + total: 10, + }); + }); + + // call handleCreateFiles with mock file + wrapper.instance().handleCreateFiles([file], "", false); + + expect(wrapper.state().uploadModalProgressVisible).toBeTruthy(); + expect(wrapper.state().uploadModalProgressPercentage).toEqual(10.0); + }); + + it("when 50% of file uploaded, bar is visible and has value of 50.0", () => { + // override our mock XMLHttpRequest's send method to send an onprogress + // event with 50% completion + mockXHR.send = jest.fn().mockImplementation(() => { + mockXHR.upload.onprogress({ + lengthComputable: true, + loaded: 5, + total: 10, + }); + }); + + // call handleCreateFiles with mock file + wrapper.instance().handleCreateFiles([file], "", false); + + expect(wrapper.state().uploadModalProgressVisible).toBeTruthy(); + expect(wrapper.state().uploadModalProgressPercentage).toEqual(50.0); + }); + + it("when 100% of file uploaded, bar is visible and has value of 100.0", () => { + // override our mock XMLHttpRequest's send method to send an onprogress + // event with 100% completion + mockXHR.send = jest.fn().mockImplementation(() => { + mockXHR.upload.onprogress({ + lengthComputable: true, + loaded: 10, + total: 10, + }); + }); + + // call handleCreateFiles with mock file + wrapper.instance().handleCreateFiles([file], "", false); + + expect(wrapper.state().uploadModalProgressVisible).toBeTruthy(); + expect(wrapper.state().uploadModalProgressPercentage).toEqual(100.0); + }); + + it("after a file is uploaded, bar is no longer visible and its value is reset to 0.0", () => { + // override existing mock implementation to allow always() to run + $.post = jest.fn().mockReturnValue({ + then: jest.fn().mockReturnThis(), + fail: jest.fn().mockReturnThis(), + always: jest.fn().mockImplementation(func => { + // allow func to run + func(); + return this; + }), + }); - // Locate the preview block - const file_viewer_comp = wrapper.find("FileViewer"); - const file_displayed = files_sample.entries.find( - file => file.url === file_viewer_comp.props().selectedFileURL - ); + // call handleCreateFiles with mock file + wrapper.instance().handleCreateFiles([file], "", false); - expect(file_viewer_comp.props().selectedFile).toEqual(file_displayed.relativeKey); - expect(file_viewer_comp.props().selectedFileType).toEqual(file_displayed.type); + expect(wrapper.state().uploadModalProgressVisible).toBeFalsy(); + expect(wrapper.state().uploadModalProgressPercentage).toEqual(0.0); }); }); }); diff --git a/app/assets/javascripts/Components/__tests__/submission_file_upload_modal.test.jsx b/app/assets/javascripts/Components/__tests__/submission_file_upload_modal.test.jsx index 538adb663c..5467b2e672 100644 --- a/app/assets/javascripts/Components/__tests__/submission_file_upload_modal.test.jsx +++ b/app/assets/javascripts/Components/__tests__/submission_file_upload_modal.test.jsx @@ -129,4 +129,154 @@ describe("For the SubmissionFileUploadModal", () => { expect(wrapper.find(".file-rename-textbox").props().disabled).toBeFalsy(); }); }); + describe("The onSubmit method", () => { + // Mock onSubmit function + const mockOnSubmit = jest.fn(); + + beforeEach(() => { + wrapper = shallow( + + ); + }); + + it("should call onSubmit prop with correct arguments when extensions match", () => { + // Mock setState method + wrapper.setState({ + newFiles: [{name: "file.py"}], + renameTo: "file.py", + }); + + // Simulate form submission + wrapper.instance().onSubmit({preventDefault: jest.fn()}); + + // Ensure onSubmit is called with expected arguments + expect(mockOnSubmit).toHaveBeenCalledWith([{name: "file.py"}], undefined, false, "file.py"); + }); + + it("should not call onSubmit prop when extensions don't match and user cancels", () => { + // Mock window.confirm to return false + window.confirm = jest.fn(() => false); + + // Mock setState method + wrapper.setState({ + newFiles: [{name: "file1.py"}], + renameTo: "file2.txt", + }); + + // Simulate form submission + wrapper.instance().onSubmit({preventDefault: jest.fn()}); + + // Ensure onSubmit is not called + expect(mockOnSubmit).not.toHaveBeenCalled(); + }); + + it("should call onSubmit prop with correct arguments when extensions don't match and user confirms", () => { + // Mock window.confirm to return true + window.confirm = jest.fn(() => true); + + // Mock setState method + wrapper.setState({ + newFiles: [{name: "file1.py"}], + renameTo: "file2.txt", + }); + + // Simulate form submission + wrapper.instance().onSubmit({preventDefault: jest.fn()}); + + // Ensure onSubmit is called with expected arguments + expect(mockOnSubmit).toHaveBeenCalledWith( + [{name: "file1.py"}], + undefined, + false, + "file2.txt" + ); + }); + + it("should call onSubmit prop with correct arguments when rename input is left blank", () => { + // Mock setState method + wrapper.setState({ + newFiles: [{name: "file.py"}], + renameTo: "", + }); + wrapper.instance().onSubmit({preventDefault: jest.fn()}); + expect(mockOnSubmit).toHaveBeenCalledWith([{name: "file.py"}], undefined, false, ""); + }); + + it("should call onSubmit prop with correct arguments when rename input consists only of whitespace", () => { + // Mock setState method + wrapper.setState({ + newFiles: [{name: "file.py"}], + renameTo: " ", + }); + wrapper.instance().onSubmit({preventDefault: jest.fn()}); + expect(mockOnSubmit).toHaveBeenCalledWith([{name: "file.py"}], undefined, false, ""); + }); + }); + + describe("The progress bar", () => { + describe("when the progressVisible prop is initially true and progressPercentage prop is 0.0", () => { + beforeEach(() => { + // for testing the behaviour of progress bar after submit + mockOnSubmit = jest.fn(() => { + wrapper.setProps({progressVisible: false}); + }); + + wrapper = shallow( + + ); + + // to ensure submit button is enabled + wrapper.setState({newFiles: ["q1.py", "q2.py"]}); + }); + + it("the progress bar is initially visible", () => { + expect(wrapper.find(".modal-progress-bar").exists()).toBeTruthy(); + }); + + it("the progress bar's value is initially 0.0", () => { + expect(wrapper.find(".modal-progress-bar").props()["value"]).toEqual(0.0); + }); + + it("the progress bar's value changes when the prop progressPercentage changes", () => { + wrapper.setProps({progressPercentage: 50.0}); + wrapper.update(); + expect(wrapper.find(".modal-progress-bar").props()["value"]).toEqual(50.0); + }); + + it("the progress bar disappears after the submit button is clicked", () => { + // simulate form submission + wrapper.instance().onSubmit({preventDefault: jest.fn()}); + expect(mockOnSubmit).toHaveBeenCalled(); + wrapper.update(); + expect(wrapper.find(".modal-progress-bar").exists()).toBeFalsy(); + }); + }); + + describe("when the progressVisible prop is initially false", () => { + beforeEach(() => { + wrapper = shallow( + {}} + /> + ); + }); + + it("the progress bar is initially not visible", () => { + expect(wrapper.find(".modal-progress-bar").exists()).toBeFalsy(); + }); + }); + }); }); diff --git a/app/assets/javascripts/Components/__tests__/submission_table.test.jsx b/app/assets/javascripts/Components/__tests__/submission_table.test.jsx new file mode 100644 index 0000000000..905edc99a9 --- /dev/null +++ b/app/assets/javascripts/Components/__tests__/submission_table.test.jsx @@ -0,0 +1,99 @@ +/*** + * Tests for SubmissionTable Component + */ + +import {SubmissionTable} from "../submission_table"; +import {render, screen, fireEvent} from "@testing-library/react"; + +jest.mock("@fortawesome/react-fontawesome", () => ({ + FontAwesomeIcon: () => { + return null; + }, +})); + +describe("For the SubmissionTable's display of inactive groups", () => { + let groups_sample; + beforeEach(() => { + groups_sample = [ + { + _id: 1, + max_mark: 21.0, + group_name: "group_0001", + tags: [], + marking_state: "released", + submission_time: "Monday, March 25, 2024, 01:49:14 PM EDT", + result_id: 1, + final_grade: 12.0, + members: [ + ["c6scriab", true], + ["g5dalber", true], + ], + grace_credits_used: 1, + }, + { + _id: 2, + max_mark: 21.0, + group_name: "group_0002", + tags: [], + marking_state: "released", + submission_time: "Monday, March 25, 2024, 01:49:14 PM EDT", + result_id: 2, + final_grade: 7.0, + members: [ + ["g8butter", false], + ["g6duparc", false], + ], + section: "LEC0101", + grace_credits_used: 1, + }, + ]; + fetch.mockReset(); + fetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + groupings: groups_sample, + sections: {}, + }), + }); + + render( + + ); + }); + + it("contains the correct amount of inactive groups in the hidden tooltip", () => { + expect(screen.getByTestId("show_inactive_groups_tooltip").getAttribute("title")).toEqual( + "1 inactive group" + ); + }); + + it("initially contains the active group", () => { + expect(screen.queryByText("group_0002")).toBeInTheDocument(); + }); + + it("initially does not contain the inactive group", () => { + expect(screen.queryByText("group_0001")).not.toBeInTheDocument(); + }); + + it("contains the inactive group after a single toggle", () => { + fireEvent.click(screen.getByTestId("show_inactive_groups")); + expect(screen.queryByText("group_0001")).toBeInTheDocument(); + }); + + it("doesn't contain the inactive group after two toggles", () => { + fireEvent.click(screen.getByTestId("show_inactive_groups")); + fireEvent.click(screen.getByTestId("show_inactive_groups")); + expect(screen.queryByText("group_0001")).not.toBeInTheDocument(); + }); +}); diff --git a/app/assets/javascripts/Components/__tests__/text_viewer.test.jsx b/app/assets/javascripts/Components/__tests__/text_viewer.test.jsx new file mode 100644 index 0000000000..30b1e10bca --- /dev/null +++ b/app/assets/javascripts/Components/__tests__/text_viewer.test.jsx @@ -0,0 +1,24 @@ +import React from "react"; +import {render, screen, cleanup} from "@testing-library/react"; +import {TextViewer} from "../Result/text_viewer"; + +describe("TextViewer", () => { + let props; + + beforeEach(() => { + props = { + content: "def f(n: int) -> int:\n return n + 1\n", + annotations: [], + focusLine: null, + submission_file_id: 1, + }; + + render(); + }); + + afterEach(cleanup); + + it("should render its text content", () => { + expect(screen.getByText("def f(n: int) -> int:")).toBeInTheDocument(); + }); +}); diff --git a/app/assets/javascripts/Components/assignment_summary_table.jsx b/app/assets/javascripts/Components/assignment_summary_table.jsx index c3de494965..b80d369e83 100644 --- a/app/assets/javascripts/Components/assignment_summary_table.jsx +++ b/app/assets/javascripts/Components/assignment_summary_table.jsx @@ -20,6 +20,8 @@ export class AssignmentSummaryTable extends React.Component { showDownloadTestsModal: false, showLtiGradeModal: false, lti_deployments: [], + filtered: [], + inactiveGroupsCount: 0, }; } @@ -27,6 +29,18 @@ export class AssignmentSummaryTable extends React.Component { this.fetchData(); } + toggleShowInactiveGroups = showInactiveGroups => { + let filtered = this.state.filtered.filter(group => { + group.id !== "inactive"; + }); + + if (!showInactiveGroups) { + filtered.push({id: "inactive", value: false}); + } + + this.setState({filtered}); + }; + memberDisplay = (group_name, members) => { if (members.length !== 0 && !(members.length === 1 && members[0][0] === group_name)) { return ( @@ -57,6 +71,19 @@ export class AssignmentSummaryTable extends React.Component { col["filterable"] = false; col["defaultSortDesc"] = true; }); + + let inactive_groups_count = 0; + res.data.forEach(group => { + if (group.members.length && group.members.every(member => member[3])) { + group.inactive = true; + inactive_groups_count++; + } else { + group.inactive = false; + } + }); + + this.toggleShowInactiveGroups(false); + const markingStates = getMarkingStates(res.data); this.setState({ data: res.data, @@ -66,6 +93,7 @@ export class AssignmentSummaryTable extends React.Component { loading: false, marking_states: markingStates, lti_deployments: res.ltiDeployments, + inactiveGroupsCount: inactive_groups_count, }); }); }; @@ -83,6 +111,11 @@ export class AssignmentSummaryTable extends React.Component { fixedColumns = () => { return [ + { + show: false, + accessor: "inactive", + id: "inactive", + }, { Header: I18n.t("activerecord.models.group.one"), id: "group_name", @@ -200,6 +233,15 @@ export class AssignmentSummaryTable extends React.Component { ); } + + let displayInactiveGroupsTooltip = ""; + + if (this.state.inactiveGroupsCount !== null) { + displayInactiveGroupsTooltip = `${I18n.t("activerecord.attributes.grouping.inactive_groups", { + count: this.state.inactiveGroupsCount, + })}`; + } + return (
@@ -218,33 +260,51 @@ export class AssignmentSummaryTable extends React.Component { {I18n.t("submissions.state.complete")}
- {this.props.is_instructor && ( -
-
- +
+ - - - {ltiButton} -
- )} + {ltiButton} + + )} +
(this.wrappedInstance = r)} diff --git a/app/assets/javascripts/Components/autotest_manager.jsx b/app/assets/javascripts/Components/autotest_manager.jsx index 72feb294ee..f8fedb91cd 100644 --- a/app/assets/javascripts/Components/autotest_manager.jsx +++ b/app/assets/javascripts/Components/autotest_manager.jsx @@ -73,6 +73,7 @@ class AutotestManager extends React.Component { enable_test: true, enable_student_tests: true, token_start_date: "", + token_end_date: "", tokens_per_period: 0, token_period: 0, non_regenerating_tokens: false, @@ -226,6 +227,11 @@ class AutotestManager extends React.Component { this.setState({token_start_date: newDate}, () => this.toggleFormChanged(true)); }; + handleTokenEndDateChange = selectedDates => { + const newDate = selectedDates[0] || ""; + this.setState({token_end_date: newDate}, () => this.toggleFormChanged(true)); + }; + handleTokensPerPeriodChange = event => { this.setState({tokens_per_period: event.target.value}, () => this.toggleFormChanged(true)); }; @@ -243,6 +249,7 @@ class AutotestManager extends React.Component { enable_test: this.state.enable_test, enable_student_tests: this.state.enable_student_tests, token_start_date: this.state.token_start_date, + token_end_date: this.state.token_end_date, tokens_per_period: this.state.tokens_per_period, token_period: this.state.token_period, non_regenerating_tokens: this.state.non_regenerating_tokens, @@ -366,6 +373,22 @@ class AutotestManager extends React.Component { plugins: [labelPlugin()], // Ensure id is applied to visible input }} /> + + diff --git a/app/assets/javascripts/Components/markdown_preview.jsx b/app/assets/javascripts/Components/markdown_preview.jsx index c02a14e382..82b665201a 100644 --- a/app/assets/javascripts/Components/markdown_preview.jsx +++ b/app/assets/javascripts/Components/markdown_preview.jsx @@ -3,8 +3,8 @@ import PropTypes from "prop-types"; export default class MarkdownPreview extends React.Component { componentDidMount = () => { - const target_id = "annotation_preview"; - MathJax.Hub.Queue(["Typeset", MathJax.Hub, target_id]); + const target_id = "#annotation-preview"; + MathJax.typeset([target_id]); }; render() { diff --git a/app/assets/javascripts/Components/one_time_annotations_table.jsx b/app/assets/javascripts/Components/one_time_annotations_table.jsx index 2af3169490..a49fb22e8e 100644 --- a/app/assets/javascripts/Components/one_time_annotations_table.jsx +++ b/app/assets/javascripts/Components/one_time_annotations_table.jsx @@ -35,13 +35,23 @@ class OneTimeAnnotationsTable extends React.Component { } }) .then(res => { - this.setState({ - data: res, - loading: false, - }); + this.setState( + { + data: res, + }, + // A separate setState call seems to be required to get the MathJax.typeset + // call in componentDidUpdate to correctly transform the annotation texts. + () => this.setState({loading: false}) + ); }); }; + componentDidUpdate(_prevProps, prevState) { + if (prevState.loading && !this.state.loading) { + MathJax.typeset(["#one_time_annotations_table_wrapper"]); + } + } + editAnnotation = (annot_id, content) => { $.ajax({ url: Routes.update_annotation_text_course_assignment_annotation_categories_path( @@ -137,15 +147,17 @@ class OneTimeAnnotationsTable extends React.Component { render() { return ( - +
+ +
); } } diff --git a/app/assets/javascripts/Components/starter_file_manager.jsx b/app/assets/javascripts/Components/starter_file_manager.jsx index 7359df418c..be844e848e 100644 --- a/app/assets/javascripts/Components/starter_file_manager.jsx +++ b/app/assets/javascripts/Components/starter_file_manager.jsx @@ -43,7 +43,7 @@ class StarterFileManager extends React.Component { this.props.course_id, this.props.assignment_id ), - {headers: {Accept: "aaplication/json"}} + {headers: {Accept: "application/json"}} ) .then(reponse => { if (reponse.ok) { @@ -225,7 +225,7 @@ class StarterFileManager extends React.Component { groupUploadTarget={id} files={files} noFilesMessage={I18n.t("submissions.no_files_available")} - readOnly={false} + readOnly={this.props.read_only} onCreateFiles={this.handleCreateFiles} onDeleteFile={this.handleDeleteFile} onCreateFolder={this.handleCreateFolder} @@ -242,9 +242,11 @@ class StarterFileManager extends React.Component { canFilter={false} /> @@ -283,7 +285,7 @@ class StarterFileManager extends React.Component { name={"starter_file_type"} value={"simple"} checked={this.state.starterfileType === "simple"} - disabled={!this.state.files.length} + disabled={!this.state.files.length || this.props.read_only} onChange={() => { this.setState({starterfileType: "simple"}, () => this.toggleFormChanged(true)); }} @@ -298,7 +300,7 @@ class StarterFileManager extends React.Component { name={"starter_file_type"} value={"sections"} checked={this.state.starterfileType === "sections"} - disabled={!this.state.files.length} + disabled={!this.state.files.length || this.props.read_only} onChange={() => { this.setState({starterfileType: "sections"}, () => this.toggleFormChanged(true)); }} @@ -313,7 +315,7 @@ class StarterFileManager extends React.Component { name={"starter_file_type"} value={"group"} checked={this.state.starterfileType === "group"} - disabled={!this.state.files.length} + disabled={!this.state.files.length || this.props.read_only} onChange={() => { this.setState({starterfileType: "group"}, () => this.toggleFormChanged(true)); }} @@ -328,7 +330,7 @@ class StarterFileManager extends React.Component { name={"starter_file_type"} value={"shuffle"} checked={this.state.starterfileType === "shuffle"} - disabled={!this.state.files.length} + disabled={!this.state.files.length || this.props.read_only} onChange={() => { this.setState({starterfileType: "shuffle"}, () => this.toggleFormChanged(true)); }} @@ -351,8 +353,9 @@ class StarterFileManager extends React.Component { this.toggleFormChanged(true) ) } + aria-label={I18n.t("assignments.starter_file.aria_labels.dropdown")} value={this.state.defaultStarterFileGroup} - disabled={!this.state.files.length} + disabled={!this.state.files.length || this.props.read_only} > {Object.entries(this.state.files).map(data => { const {id, name} = data[1]; @@ -383,7 +386,8 @@ class StarterFileManager extends React.Component { { this.setState( prev => ({available_after_due: !prev.available_after_due}), () => this.toggleFormChanged(true) ); }} + disabled={this.props.read_only} /> {I18n.t("assignments.starter_file.available_after_due")} @@ -477,9 +483,11 @@ class StarterFileManager extends React.Component { {this.renderFileManagers()} @@ -529,8 +537,9 @@ class StarterFileManager extends React.Component {

@@ -644,3 +653,5 @@ class StarterFileFileManager extends React.Component { export function makeStarterFileManager(elem, props) { render(, elem); } + +export {StarterFileManager}; diff --git a/app/assets/javascripts/Components/submission_file_manager.jsx b/app/assets/javascripts/Components/submission_file_manager.jsx index b79257d3dc..c495523263 100644 --- a/app/assets/javascripts/Components/submission_file_manager.jsx +++ b/app/assets/javascripts/Components/submission_file_manager.jsx @@ -4,7 +4,7 @@ import FileManager from "./markus_file_manager"; import SubmissionFileUploadModal from "./Modals/submission_file_upload_modal"; import SubmitUrlUploadModal from "./Modals/submission_url_submit_modal"; import {FileViewer} from "./Result/file_viewer"; -import {getType} from "mime/lite"; +import mime from "mime/lite"; import {flashMessage} from "../flash"; class SubmissionFileManager extends React.Component { @@ -22,6 +22,8 @@ class SubmissionFileManager extends React.Component { requiredFiles: [], maxFileSize: 0, numberOfMissingFiles: 0, + uploadModalProgressVisible: false, + uploadModalProgressPercentage: 0.0, }; } @@ -101,8 +103,9 @@ class SubmissionFileManager extends React.Component { !this.props.starterFileChanged || confirm(I18n.t("assignments.starter_file.upload_confirmation")) ) { + this.setState({uploadModalProgressVisible: true}); + const prefix = path || this.state.uploadTarget || ""; - this.setState({showUploadModal: false, uploadTarget: undefined}); let data = new FormData(); if (!!renameTo && files.length === 1) { Array.from(files).forEach(f => data.append("new_files[]", f, renameTo)); @@ -114,6 +117,7 @@ class SubmissionFileManager extends React.Component { data.append("grouping_id", this.props.grouping_id); } data.append("unzip", unzip); + $.post({ url: Routes.update_files_course_assignment_submissions_path( this.props.course_id, @@ -122,6 +126,21 @@ class SubmissionFileManager extends React.Component { data: data, processData: false, // tell jQuery not to process the data contentType: false, // tell jQuery not to set contentType + xhr: () => { + const xhr = new XMLHttpRequest(); + + xhr.upload.addEventListener( + "progress", + event => { + if (event.lengthComputable) { + this.setState({uploadModalProgressPercentage: (event.loaded / event.total) * 100}); + } + }, + false + ); + + return xhr; + }, }) .then(typeof this.props.onChange === "function" ? this.props.onChange : this.fetchData) .then(this.endAction) @@ -129,6 +148,14 @@ class SubmissionFileManager extends React.Component { if (jqXHR.getResponseHeader("x-message-error") == null) { flashMessage(I18n.t("upload_errors.generic"), "error"); } + }) + .always(() => { + this.setState({ + showUploadModal: false, + uploadTarget: undefined, + uploadModalProgressVisible: false, + uploadModalProgressPercentage: 0.0, + }); }); } }; @@ -246,7 +273,7 @@ class SubmissionFileManager extends React.Component { selectedFile={this.state.viewFile} selectedFileType={this.state.viewFileType} selectedFileURL={this.state.viewFileURL} - mime_type={getType(this.state.viewFile)} + mime_type={mime.getType(this.state.viewFile)} />
); @@ -353,6 +380,8 @@ class SubmissionFileManager extends React.Component { isOpen={this.state.showUploadModal} onRequestClose={() => this.setState({showUploadModal: false, uploadTarget: undefined})} onSubmit={this.handleCreateFiles} + progressVisible={this.state.uploadModalProgressVisible} + progressPercentage={this.state.uploadModalProgressPercentage} onlyRequiredFiles={this.state.onlyRequiredFiles} requiredFiles={this.state.requiredFiles} uploadTarget={this.state.uploadTarget} diff --git a/app/assets/javascripts/Components/submission_table.jsx b/app/assets/javascripts/Components/submission_table.jsx index 7e7741ebf0..c1b4322c6e 100644 --- a/app/assets/javascripts/Components/submission_table.jsx +++ b/app/assets/javascripts/Components/submission_table.jsx @@ -11,7 +11,7 @@ import { } from "./Helpers/table_helpers"; import CollectSubmissionsModal from "./Modals/collect_submissions_modal"; import ReleaseUrlsModal from "./Modals/release_urls_modal"; -import consumer from "/app/javascript/channels/consumer"; +import consumer from "../../../../app/javascript/channels/consumer"; import {renderFlashMessages} from "../flash"; class RawSubmissionTable extends React.Component { @@ -26,6 +26,8 @@ class RawSubmissionTable extends React.Component { showReleaseUrlsModal: false, marking_states: markingStates, markingStateFilter: "all", + inactiveGroupsCount: 0, + filtered: [], }; } @@ -50,12 +52,27 @@ class RawSubmissionTable extends React.Component { }) .then(res => { this.props.resetSelection(); + + let inactive_groups_count = 0; + res.groupings.forEach(group => { + if (group.members.length && group.members.every(member => member[1])) { + group.inactive = true; + inactive_groups_count++; + } else { + group.inactive = false; + } + }); + + this.toggleShowInactiveGroups(false); + const markingStates = getMarkingStates(res.groupings); + this.setState({ groupings: res.groupings, sections: res.sections, loading: false, marking_states: markingStates, + inactiveGroupsCount: inactive_groups_count, }); }); }; @@ -69,6 +86,8 @@ class RawSubmissionTable extends React.Component { const markingStateFilter = filtered.find(filter => filter.id == "marking_state").value; this.setState({markingStateFilter: markingStateFilter}); } + + this.setState({filtered}); }; groupNameWithMembers = row => { @@ -79,7 +98,7 @@ class RawSubmissionTable extends React.Component { ) { members = ""; } else { - members = ` (${row.original.members.join(", ")})`; + members = ` (${row.original.members.map(m => m[0]).join(", ")})`; } return row.value + members; }; @@ -100,6 +119,11 @@ class RawSubmissionTable extends React.Component { }; columns = () => [ + { + show: false, + accessor: "inactive", + id: "inactive", + }, { show: false, accessor: "_id", @@ -242,6 +266,7 @@ class RawSubmissionTable extends React.Component { // Custom getTrProps function to highlight submissions that have been collected. getTrProps = (state, ri, ci, instance) => { if ( + ri === undefined || ri.original.marking_state === undefined || ri.original.marking_state === "not_collected" || ri.original.marking_state === "before_due_date" @@ -413,6 +438,18 @@ class RawSubmissionTable extends React.Component { ); }; + toggleShowInactiveGroups = showInactiveGroups => { + let filtered = this.state.filtered.filter(group => { + group.id !== "inactive"; + }); + + if (!showInactiveGroups) { + filtered.push({id: "inactive", value: false}); + } + + this.setState({filtered}); + }; + render() { const {loading} = this.state; @@ -438,6 +475,8 @@ class RawSubmissionTable extends React.Component { incompleteResults={() => this.setMarkingStates("incomplete")} authenticity_token={this.props.authenticity_token} release_with_urls={this.props.release_with_urls} + inactiveGroupsCount={this.state.inactiveGroupsCount} + updateShowInactiveGroups={this.toggleShowInactiveGroups} /> (this.checkboxTable = r)} @@ -450,6 +489,7 @@ class RawSubmissionTable extends React.Component { ]} filterable defaultFiltered={this.props.defaultFiltered} + filtered={this.state.filtered} onFilteredChange={this.onFilteredChange} loading={loading} getTrProps={this.getTrProps} @@ -499,7 +539,8 @@ class SubmissionsActionBox extends React.Component { } render = () => { - let completeButton, + let displayInactiveGroupsCheckbox, + completeButton, incompleteButton, collectButton, runTestsButton, @@ -507,6 +548,35 @@ class SubmissionsActionBox extends React.Component { unreleaseMarksButton, showReleaseUrlsButton; + let displayInactiveGroupsTooltip = ""; + + if (this.props.inactiveGroupsCount !== null) { + displayInactiveGroupsTooltip = `${I18n.t("activerecord.attributes.grouping.inactive_groups", { + count: this.props.inactiveGroupsCount, + })}`; + } + + displayInactiveGroupsCheckbox = ( + <> + this.props.updateShowInactiveGroups(e.target.checked)} + className={"hide-user-checkbox"} + data-testid={"show_inactive_groups"} + /> + + + ); + completeButton = (