diff --git a/.annotaterb.yml b/.annotaterb.yml new file mode 100644 index 0000000000..2c665f77be --- /dev/null +++ b/.annotaterb.yml @@ -0,0 +1,65 @@ +--- +:position: before +:position_in_additional_file_patterns: before +:position_in_class: before +:position_in_factory: before +:position_in_fixture: before +:position_in_routes: before +:position_in_serializer: before +:position_in_test: before +:classified_sort: true +:exclude_controllers: true +:exclude_factories: true +:exclude_fixtures: true +:exclude_helpers: true +:exclude_scaffolds: true +:exclude_serializers: true +:exclude_sti_subclasses: false +:exclude_tests: true +:force: false +:format_markdown: false +:format_rdoc: false +:format_yard: false +:frozen: false +:grouped_polymorphic: false +:ignore_model_sub_dir: false +:ignore_unknown_models: false +:include_version: false +:show_check_constraints: false +:show_complete_foreign_keys: false +:show_foreign_keys: true +:show_indexes: true +:show_indexes_include: false +:simple_indexes: false +:sort: false +:timestamp: false +:trace: false +:with_comment: true +:with_column_comments: true +:with_table_comments: true +:position_of_column_comment: :with_name +:active_admin: false +:command: +:debug: false +:hide_default_column_types: '' +:hide_limit_column_types: '' +:timestamp_columns: +- created_at +- updated_at +:ignore_columns: +:ignore_routes: +:ignore_multi_database_name: false +:models: true +:routes: false +:skip_on_db_migrate: false +:target_action: :do_annotations +:wrapper: +:wrapper_close: 'rubocop:enable Layout/LineLength, Lint/RedundantCopDisableDirective' +:wrapper_open: 'rubocop:disable Layout/LineLength, Lint/RedundantCopDisableDirective' +:classes_default_to_s: [] +:additional_file_patterns: [] +:model_dir: +- app/models +:require: [] +:root_dir: +- '' diff --git a/.dockerfiles/Dockerfile b/.dockerfiles/Dockerfile index c86f0fba6b..16beb04f2f 100644 --- a/.dockerfiles/Dockerfile +++ b/.dockerfiles/Dockerfile @@ -1,8 +1,8 @@ -FROM ubuntu:jammy AS base +FROM ubuntu:24.04 AS base -ARG NODE_MAJOR=20 -ARG BUNDLER_VERSION='2.4.13' -ARG RUBY_VERSION='3.3.4' +ARG NODE_MAJOR=24 +ARG BUNDLER_VERSION='4.0.10' +ARG RUBY_VERSION='3.4.9' ARG USER=markus # Required in order to ensure bind-mounts are owned by the correct user inside the container @@ -10,29 +10,38 @@ ARG USER=markus # Set this to the same UID as the user that owns the Markus files on the host machine. ARG UID=1001 +# Remove ubuntu user, added in the 23.04 image +RUN userdel -r ubuntu + # Create the user that runs the app RUN useradd -m -u $UID -s /bin/bash $USER -# Set up the correct node version for later installation -ADD https://deb.nodesource.com/setup_$NODE_MAJOR.x /tmp/setup_node.sh -RUN sh /tmp/setup_node.sh +# Set up the correct node version AND PostgreSQL repo for later installation +RUN apt-get update && \ + DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends wget ca-certificates sudo gnupg lsb-release && \ + install -m 0755 -d /etc/apt/keyrings && \ + wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /etc/apt/keyrings/postgresql.gpg && \ + echo "deb [signed-by=/etc/apt/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list && \ + wget -O /tmp/setup_node.sh https://deb.nodesource.com/setup_$NODE_MAJOR.x && \ + sh /tmp/setup_node.sh && \ + rm /tmp/setup_node.sh # Copy the debian package containing system dependencies COPY markus_1.0_all.deb / -# Install basic system dependencies +# Install basic system dependencies and the specific PG client version RUN apt-get update && \ - DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends /markus_1.0_all.deb && \ - rm /tmp/setup_node.sh /markus_1.0_all.deb - -# Install Ruby (we use ruby-install to configure the installed ruby version). -RUN DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends wget ca-certificates sudo && \ - wget https://github.com/postmodern/ruby-install/releases/download/v0.9.3/ruby-install-0.9.3.tar.gz && \ - tar -xzvf ruby-install-0.9.3.tar.gz && \ - cd ruby-install-0.9.3/ && \ - make install && \ - ruby-install --update && \ - ruby-install --system ruby $RUBY_VERSION + DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends \ + /markus_1.0_all.deb \ + postgresql-client-17 && \ + rm /markus_1.0_all.deb + +# Install Ruby from precompiled binaries. +# See https://github.com/jdx/ruby +RUN ARCH=$(uname -m | sed 's/x86_64/x86_64_linux/;s/aarch64/arm64_linux/') && \ + wget -O /tmp/ruby.tar.gz https://github.com/jdx/ruby/releases/download/$RUBY_VERSION/ruby-$RUBY_VERSION.$ARCH.tar.gz && \ + tar --strip-components=1 -C /usr/local -xzf /tmp/ruby.tar.gz && \ + rm /tmp/ruby.tar.gz # Enable reading of PDF files with imagemagick RUN sed -ri 's/(rights=")none("\s+pattern="PDF")/\1read\2/' /etc/ImageMagick-6/policy.xml @@ -81,6 +90,7 @@ RUN apt-get update -qq && \ DEBIAN_FRONTEND=noninteractive add-apt-repository -y ppa:deadsnakes/ppa && \ DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends openssh-server \ python3.13 \ + python3.13-dev \ python3.13-venv \ equivs \ libjemalloc2 @@ -133,7 +143,7 @@ ENV RAILS_ENV=production ENV NODE_ENV=production # Install gems -RUN SECRET_KEY_BASE=1 bundle install --deployment +RUN SECRET_KEY_BASE=1 BUNDLE_DEPLOYMENT=1 bundle install # Precompile assets RUN SECRET_KEY_BASE=1 NO_SCHEMA_VALIDATE=true NO_INIT_SCHEDULER=true PGDATABASE=dummy bundle exec rails assets:precompile diff --git a/.dockerfiles/entrypoint-dev-rails.sh b/.dockerfiles/entrypoint-dev-rails.sh index bd450fe9f7..2091b51fbc 100755 --- a/.dockerfiles/entrypoint-dev-rails.sh +++ b/.dockerfiles/entrypoint-dev-rails.sh @@ -20,8 +20,5 @@ sed -i -e :a -e '/^\n*$/{$d;N;};/\n$/ba' /app/db/structure.sql rm -f ./tmp/pids/server.pid -# 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/.github/dependabot.yml b/.github/dependabot.yml index 2af4851e83..32405a2022 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,5 +1,9 @@ version: 2 updates: +- package-ecosystem: github-actions + directory: "/" + schedule: + interval: quarterly - package-ecosystem: bundler directory: "/" schedule: diff --git a/.github/workflows/test_ci.yml b/.github/workflows/test_ci.yml index 0465d5e993..5f828167a8 100644 --- a/.github/workflows/test_ci.yml +++ b/.github/workflows/test_ci.yml @@ -9,10 +9,10 @@ on: jobs: test_rspec: if: github.event.pull_request.draft == false - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 services: postgres: - image: postgres:14 + image: postgres:17 env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres @@ -42,13 +42,20 @@ jobs: contents: read steps: - name: Checkout repo - uses: actions/checkout@v4 + uses: actions/checkout@v6 + - name: Add PostgreSQL apt repository + run: | + sudo apt-get install -y curl ca-certificates + sudo install -d /usr/share/postgresql-common/pgdg + sudo curl -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc --fail https://www.postgresql.org/media/keys/ACCC4CF8.asc + sudo sh -c 'echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' - name: Install and cache system dependencies uses: awalsh128/cache-apt-pkgs-action@latest with: packages: | libpq-dev cmake + pkg-config ghostscript pandoc imagemagick @@ -59,7 +66,7 @@ jobs: pandoc poppler-utils fonts-liberation - libasound2 + libasound2t64 libatk-bridge2.0-0 libatk1.0-0 libatspi2.0-0 @@ -83,23 +90,25 @@ jobs: libxfixes3 libxrandr2 libxshmfence1 - version: 1.0 + postgresql-client-17 + version: 1.0.2 # Packages 'fonts-liberation' and onward are needed for playwright's chromium installation. # The list was taken from: https://github.com/microsoft/playwright/blob/main/packages/playwright-core/src/server/registry/nativeDeps.ts#L37-L63 + # libasound2 renamed to libasound2t64 in Ubuntu 24.04 - name: Set up ruby and cache gems uses: ruby/setup-ruby@v1 with: - ruby-version: ruby-3.3 + ruby-version: ruby-3.4 bundler-cache: true - name: Set up node and cache packages - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: 18 + node-version: 24 cache: npm - name: Install npm packages run: npm ci - name: Install python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.13" - name: Get pip cache dir @@ -107,14 +116,14 @@ jobs: run: | echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT - name: Cache pip - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ${{ steps.pip-cache.outputs.dir }} key: ${{ runner.os }}-pip-${{ hashFiles('requirements-jupyter.txt') }}-${{ hashFiles('requirements-scanner.txt') }} restore-keys: | ${{ runner.os }}-pip- - name: Cache playwright's installation of Chromium - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.cache/ms-playwright key: ${{ runner.os }}-playwright-${{ hashFiles('requirements-jupyter.txt') }} @@ -132,9 +141,7 @@ 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 - bundle exec rake css:build + run: bundle exec rake javascript:build - name: Set up database run: bundle exec rails db:migrate - name: Install chromedriver @@ -159,7 +166,7 @@ jobs: test_jest: if: github.event.pull_request.draft == false - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 services: redis: image: redis @@ -174,15 +181,16 @@ jobs: permissions: contents: read env: + BUNDLE_WITHOUT: development:production:console:unicorn RAILS_ENV: test NODE_ENV: test steps: - name: Checkout repo - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up ruby and cache gems uses: ruby/setup-ruby@v1 with: - ruby-version: ruby-3.3 + ruby-version: ruby-3.4 bundler-cache: true - name: Configure server run: | @@ -196,9 +204,9 @@ jobs: run: | bundle exec rake javascript:build - name: Set up node and cache packages - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: 18 + node-version: 24 cache: npm - name: Install npm packages run: npm ci @@ -215,7 +223,7 @@ jobs: finish: needs: [test_rspec, test_jest] if: github.event.pull_request.draft == false - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 permissions: pull-requests: write steps: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 19f0d6c978..c846fb1021 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,12 +17,12 @@ repos: - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/rbubley/mirrors-prettier - rev: v3.7.3 + rev: v3.8.3 hooks: - id: prettier types_or: [javascript, jsx, css, scss, html] - repo: https://github.com/thibaudcolas/pre-commit-stylelint - rev: v16.26.1 + rev: v17.10.0 hooks: - id: stylelint additional_dependencies: [ @@ -39,7 +39,7 @@ repos: app/assets/stylesheets/common/_reset.scss )$ - repo: https://github.com/rubocop/rubocop - rev: v1.81.7 + rev: v1.86.1 hooks: - id: rubocop args: ["--autocorrect"] @@ -53,11 +53,11 @@ repos: )$ additional_dependencies: - rubocop-rails:2.33.4 - - rubocop-performance:1.23.0 - - rubocop-factory_bot:2.26.1 + - rubocop-performance:1.26.1 + - rubocop-factory_bot:2.28.0 - rubocop-rspec:3.2.0 - - rubocop-rspec_rails:2.30.0 - - rubocop-capybara:2.21.0 + - rubocop-rspec_rails:2.32.0 + - rubocop-capybara:2.22.1 - repo: local hooks: - id: erb_lint @@ -73,6 +73,14 @@ repos: )$ additional_dependencies: - erb_lint:0.9.0 + - id: i18n-tasks-health + name: i18n-tasks health + entry: i18n-tasks health + language: ruby + files: config/locales/ + pass_filenames: false + additional_dependencies: + - i18n-tasks exclude: vendor diff --git a/.rubocop.yml b/.rubocop.yml index 68812dd020..6104fdcbc7 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,11 +1,10 @@ -require: - - rubocop-performance - - rubocop-factory_bot - - rubocop-rspec_rails - - rubocop-capybara plugins: + - rubocop-capybara + - rubocop-performance - rubocop-rails - rubocop-rspec + - rubocop-rspec_rails + - rubocop-factory_bot AllCops: Exclude: @@ -29,6 +28,9 @@ FactoryBot/ConsistentParenthesesStyle: Layout/EmptyLineAfterGuardClause: Enabled: false +Layout/IndentationWidth: + Enabled: false + Layout/LineEndStringConcatenationIndentation: Enabled: true @@ -45,6 +47,9 @@ Layout/FirstHashElementIndentation: Layout/LineLength: Max: 120 +Layout/MultilineMethodCallIndentation: + Enabled: false + # Disable to allow method chaining aligned at the '.' Layout/MultilineOperationIndentation: Enabled: false @@ -348,6 +353,10 @@ Style/NumberedParametersLimit: Style/NumericPredicate: Enabled: false +# TODO: enable this check +Style/OneClassPerFile: + Enabled: false + Style/OpenStructUse: Enabled: false diff --git a/Changelog.md b/Changelog.md index 68fa6292ca..ef7b8d1868 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,16 +1,62 @@ # Changelog -## [unreleased] - -### 🛡️ Security - -### 🚨 Breaking changes +## [v2.10.0] ### ✨ New features and improvements +- Improve admin user list loading time by replacing ActiveRecord instantiation with direct column extraction (#7897) +- Provide suggestions for partial student matching scans (#7760) +- Allow inactive students to join groups (#7757) +- Update autotest settings form UI (#7777) +- Split courses into Current and Past sections for all users (#7801) +- Add an administrator action on the course settings page to refresh autotest schema (#7828) +- Add JS autotester example (#7866) +- Return structured JSON from grade entry forms API show endpoint with optional student filter and CSV export (#7886) +- Added term-based suffixes to course names created via LTI to ensure uniqueness across academic years (#7881) +- Added `db:populate_course_dates` rake task to backfill `start_at`/`end_at` for existing courses, and permit those fields when creating courses through the admin UI (#7925) +- Sync due date when creating or updating LTI gradebook line items, and re-sync automatically when an assessment is edited (#7872) ### 🐛 Bug fixes +- Prevent "No rows found" message from displaying in tables when data is loading (#7790) +- Fixed course card link behavior on the dashboard (#7887) +- Fixed reserved interpolation key `%{format}` in `download_errors.unrecognized_format` locale string, renamed to `%{file_format}` (#7894) +- Fix version mismatch between container client and database server (#7916) +- Fixed filter Canvas Test Student from roster sync (#7926) +- Fix: include original total mark in JSON response for remark requests (#7945) +- Fixed `(hidden)` assignment labeling for assignments with `visible_on` and/or `visible_until` set (#7944) ### 🔧 Internal changes +- Fixed flaky test `can bulk assign duplicated TAs to grade entry students` in `/spec/models/grade_entry_student_spec.rb` (#7958) +- Added tests for `GroupsController` to fully cover `global_actions` (#7955) +- Added tests for `graders_controller` to fully cover `grader_criteria_mapping` function (#7949) +- Added tests for `GradersController` to fully cover `grader_groupers_mapping` (#7946) +- Added seed task to assign TAs to A1 groupings and criteria (#7867) +- Updated autotest seed files to ensure settings follow tester JSON schema (#7775) +- Refactored grade entry form helper logic into `GradeEntryFormsController` and removed the newly-unused helper file. (#7789) +- Added tests for `GradeEntryFormsController` to fully cover `update_grade_entry_form_params` (#7789) +- Updated the grade breakdown summary table to use `@tanstack/react-table` v8 (#7800) +- Internationalized custom model validation error messages by replacing hardcoded English strings with i18n symbol keys (#7805) +- Changed model validation errors to use built-in error key resolution instead of inline `I18n.t` calls (#7806) +- Upgraded to Rails v8.1 (#7815) +- Updated the student table to use `@tanstack/react-table` v8 (#7826) +- Added `active_record_doctor` development gem (#7861) +- Added `annotaterb` development gem and added model and route annotations (#7861) +- Added `pghero` gem and added PgHero admin dashboard (#7861) +- Add nullable last_updated_by foreign key (to roles) to marks and grades tables to track who assigned which grade (#7878) +- Updates ResultsController#update_mark to set the last_updated_by field when marks are modified (#7885) +- Added pre-commit hook to run `i18n-tasks health` when locale files are changed (#7894) +- Refactored tables to avoid recreating column definitions on every render (#7910) +- Updated Docker configuration to use Ubuntu 24.04, Ruby 3.4.9, Bundler 4.0.10, Node 24, and Postgres 17 (#7911) +- Update Dockerfile to include the python3.13-dev package which allows building c++ extensions from source (#7912) +- Improved performance of `Table` component (#7919) +- Updated GitHub Actions dependencies and added Dependabot config for quarterly GitHub Actions updates (#7920) +- Updated `pdfjs-dist` to v5.6.205 (#7942) +- Switched SCSS files to use `@use` instead of `@import` to reduce bundle size (#7943) +- Fixed flaky git-hooks tests (#7950) +- Replaced custom `HTMLElement` class methods with native versions and removed `application.js` (#7951) +- Moved annotation-related Javascript to be bundled with webpack (#7953) +- Upgraded `react-jsonschema-form` to v6.5.2 (#7954) +- Fixed CSP warnings and updated CSP config. Switched webpack development source map to `eval-source-map`. (#7956) +- Move all CSS builds into webpack pipeline (#7957) ## [v2.9.6] diff --git a/Gemfile b/Gemfile index 6a066f2b82..59d004b3f5 100644 --- a/Gemfile +++ b/Gemfile @@ -8,7 +8,7 @@ source 'https://rubygems.org' # Bundler requires these gems in all environments gem 'puma' -gem 'rails', '~> 8.0.3' +gem 'rails', '~> 8.1.2' gem 'sprockets' gem 'sprockets-rails' @@ -37,7 +37,7 @@ gem 'histogram' # Internationalization gem 'i18n' gem 'i18n-js' -gem 'rails-i18n', '~> 8.0.2' +gem 'rails-i18n', '~> 8.1.0' # Redis gem 'redis', '~> 5.4.1' @@ -46,7 +46,7 @@ gem 'redis', '~> 5.4.1' gem 'combine_pdf' gem 'prawn' gem 'prawn-qrcode' -gem 'rmagick', '~> 6.1.4' +gem 'rmagick', '~> 6.2.0' gem 'rtesseract' # Ruby miscellany @@ -64,6 +64,7 @@ gem 'cookies_eu' gem 'dry-validation' # For settings schema validation gem 'exception_notification' gem 'marcel' +gem 'pghero' gem 'rails-html-sanitizer' gem 'rails_performance' gem 'responders' @@ -77,6 +78,7 @@ gem 'pg' # Gems only used for development should be listed here so that they # are not loaded in other environments. group :development do + gem 'annotaterb', require: false gem 'awesome_print' gem 'better_errors' gem 'binding_of_caller' # supplement for better_errors @@ -96,7 +98,7 @@ group :test do gem 'rails-controller-testing' gem 'shoulda-callback-matchers', '~> 1.1.1' gem 'shoulda-context', '~> 3.0.0.rc1' - gem 'shoulda-matchers', '~> 6.5' + gem 'shoulda-matchers', '~> 7.0' gem 'simplecov', require: false gem 'simplecov-lcov', require: false gem 'timecop' @@ -106,11 +108,12 @@ end # Gems needed (wanted) for both development and test can be # listed here group :development, :test do + gem 'active_record_doctor', require: false gem 'bullet' gem 'capybara' gem 'debug', '>= 1.0.0' gem 'i18n-tasks', require: false - gem 'rspec-rails', '~> 8.0.2' + gem 'rspec-rails', '~> 8.0.4' gem 'selenium-webdriver' end diff --git a/Gemfile.lock b/Gemfile.lock index 41a49404b3..5586ac0305 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,29 +3,31 @@ GEM specs: action_policy (0.7.5) ruby-next-core (>= 1.0) - actioncable (8.0.3) - actionpack (= 8.0.3) - activesupport (= 8.0.3) + action_text-trix (2.1.18) + railties + actioncable (8.1.2.1) + actionpack (= 8.1.2.1) + activesupport (= 8.1.2.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (8.0.3) - actionpack (= 8.0.3) - activejob (= 8.0.3) - activerecord (= 8.0.3) - activestorage (= 8.0.3) - activesupport (= 8.0.3) + actionmailbox (8.1.2.1) + actionpack (= 8.1.2.1) + activejob (= 8.1.2.1) + activerecord (= 8.1.2.1) + activestorage (= 8.1.2.1) + activesupport (= 8.1.2.1) mail (>= 2.8.0) - actionmailer (8.0.3) - actionpack (= 8.0.3) - actionview (= 8.0.3) - activejob (= 8.0.3) - activesupport (= 8.0.3) + actionmailer (8.1.2.1) + actionpack (= 8.1.2.1) + actionview (= 8.1.2.1) + activejob (= 8.1.2.1) + activesupport (= 8.1.2.1) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (8.0.3) - actionview (= 8.0.3) - activesupport (= 8.0.3) + actionpack (8.1.2.1) + actionview (= 8.1.2.1) + activesupport (= 8.1.2.1) nokogiri (>= 1.8.5) rack (>= 2.2.4) rack-session (>= 1.0.1) @@ -33,72 +35,77 @@ GEM rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) - actiontext (8.0.3) - actionpack (= 8.0.3) - activerecord (= 8.0.3) - activestorage (= 8.0.3) - activesupport (= 8.0.3) + actiontext (8.1.2.1) + action_text-trix (~> 2.1.15) + actionpack (= 8.1.2.1) + activerecord (= 8.1.2.1) + activestorage (= 8.1.2.1) + activesupport (= 8.1.2.1) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (8.0.3) - activesupport (= 8.0.3) + actionview (8.1.2.1) + activesupport (= 8.1.2.1) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (8.0.3) - activesupport (= 8.0.3) + active_record_doctor (2.0.1) + activerecord (>= 7.0.0) + activejob (8.1.2.1) + activesupport (= 8.1.2.1) globalid (>= 0.3.6) activejob-status (1.0.1) activejob (>= 6.0) activesupport (>= 6.0) - activemodel (8.0.3) - activesupport (= 8.0.3) + activemodel (8.1.2.1) + activesupport (= 8.1.2.1) activemodel-serializers-xml (1.0.3) activemodel (>= 5.0.0.a) activesupport (>= 5.0.0.a) builder (~> 3.1) - activerecord (8.0.3) - activemodel (= 8.0.3) - activesupport (= 8.0.3) + activerecord (8.1.2.1) + activemodel (= 8.1.2.1) + activesupport (= 8.1.2.1) timeout (>= 0.4.0) - activestorage (8.0.3) - actionpack (= 8.0.3) - activejob (= 8.0.3) - activerecord (= 8.0.3) - activesupport (= 8.0.3) + activestorage (8.1.2.1) + actionpack (= 8.1.2.1) + activejob (= 8.1.2.1) + activerecord (= 8.1.2.1) + activesupport (= 8.1.2.1) marcel (~> 1.0) - activesupport (8.0.3) + activesupport (8.1.2.1) base64 - benchmark (>= 0.3) bigdecimal concurrent-ruby (~> 1.0, >= 1.3.1) connection_pool (>= 2.2.5) drb i18n (>= 1.6, < 2) + json logger (>= 1.4.2) minitest (>= 5.1) securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) uri (>= 0.13.1) - addressable (2.8.7) - public_suffix (>= 2.0.2, < 7.0) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) + annotaterb (4.22.0) + activerecord (>= 6.0.0) + activesupport (>= 6.0.0) ast (2.4.3) autoprefixer-rails (10.4.21.0) execjs (~> 2) awesome_print (1.9.2) base64 (0.3.0) - benchmark (0.5.0) better_errors (2.10.1) erubi (>= 1.0.0) rack (>= 0.9.0) rouge (>= 1.0.0) bigdecimal (3.3.1) - binding_of_caller (1.0.1) + binding_of_caller (2.0.0) debug_inspector (>= 1.2.0) - bootsnap (1.18.6) + bootsnap (1.24.0) msgpack (~> 1.2) - brakeman (7.1.0) + brakeman (8.0.4) racc browser (6.2.0) builder (3.3.0) @@ -118,11 +125,11 @@ GEM combine_pdf (1.0.31) matrix ruby-rc4 (>= 0.1.5) - concurrent-ruby (1.3.5) + concurrent-ruby (1.3.6) config (5.6.1) deep_merge (~> 1.2, >= 1.2.1) ostruct - connection_pool (2.5.4) + connection_pool (3.0.2) cookies_eu (1.7.8) js_cookie_rails (~> 2.2.0) crack (1.0.1) @@ -132,7 +139,7 @@ GEM cssbundling-rails (1.4.3) railties (>= 6.0.0) csv (3.3.5) - date (3.4.1) + date (3.5.1) debug (1.11.0) irb (~> 1.10) reline (>= 0.3.8) @@ -177,7 +184,7 @@ GEM dry-initializer (~> 3.2) dry-schema (~> 1.14) zeitwerk (~> 2.6) - erb (5.0.2) + erb (6.0.4) erubi (1.13.1) et-orbi (1.2.11) tzinfo @@ -185,12 +192,12 @@ GEM actionmailer (>= 7.1, < 9) activesupport (>= 7.1, < 9) execjs (2.10.0) - factory_bot (6.5.4) + factory_bot (6.5.6) activesupport (>= 6.1.0) - factory_bot_rails (6.5.0) + factory_bot_rails (6.5.1) factory_bot (~> 6.5) railties (>= 6.1.0) - faker (3.5.2) + faker (3.8.0) i18n (>= 1.8.11, < 2) ffi (1.16.3) fugit (1.11.1) @@ -206,7 +213,7 @@ GEM highline (3.1.2) reline histogram (0.2.4.1) - i18n (1.14.7) + i18n (1.14.8) concurrent-ruby (~> 1.0) i18n-js (4.2.3) glob (>= 0.4.0) @@ -222,12 +229,13 @@ GEM rainbow (>= 2.2.2, < 4.0) ruby-progressbar (~> 1.8, >= 1.8.1) terminal-table (>= 1.5.1) - io-console (0.8.1) - irb (1.15.2) + io-console (0.8.2) + irb (1.17.0) pp (>= 0.6.0) + prism (>= 1.3.0) rdoc (>= 4.0.0) reline (>= 0.4.2) - js-routes (2.3.5) + js-routes (2.3.6) railties (>= 5) sorbet-runtime js_cookie_rails (2.2.0) @@ -247,7 +255,8 @@ GEM crass (~> 1.0.2) nokogiri (>= 1.12.0) machinist (2.0) - mail (2.8.1) + mail (2.9.0) + logger mini_mime (>= 0.1.1) net-imap net-pop @@ -256,13 +265,15 @@ GEM matrix (0.4.2) mini_mime (1.1.5) mini_portile2 (2.8.9) - minitest (5.26.0) + minitest (6.0.6) + drb (~> 2.0) + prism (~> 1.5) mono_logger (1.1.2) msgpack (1.8.0) multi_json (1.15.0) mustermann (3.0.4) ruby2_keywords (~> 0.0.1) - net-imap (0.5.11) + net-imap (0.6.4) date net-protocol net-pop (0.1.2) @@ -272,7 +283,7 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.5) - nokogiri (1.19.1) + nokogiri (1.19.3) mini_portile2 (~> 2.8.2) racc (~> 1.4) observer (0.1.2) @@ -281,12 +292,14 @@ GEM ast (~> 2.4.1) racc pdf-core (0.10.0) - pg (1.6.2) + pg (1.6.3) + pghero (3.8.0) + activerecord (>= 7.2) pkg-config (1.6.5) pluck_to_hash (1.0.2) activerecord (>= 4.0.2) activesupport (>= 4.0.2) - pp (0.6.2) + pp (0.6.3) prettyprint prawn (2.5.0) matrix (~> 0.4) @@ -296,16 +309,16 @@ GEM prawn (>= 1) rqrcode (>= 1.0.0) prettyprint (0.2.0) - prism (1.6.0) - psych (5.2.6) + prism (1.9.0) + psych (5.3.1) date stringio - public_suffix (6.0.2) - puma (7.1.0) + public_suffix (7.0.5) + puma (7.2.0) nio4r (~> 2.0) raabro (1.4.0) racc (1.8.1) - rack (3.2.5) + rack (3.2.6) rack-cors (3.0.0) logger rack (>= 3.0.14) @@ -313,27 +326,27 @@ GEM base64 (>= 0.1.0) logger (>= 1.6.0) rack (>= 3.0.0, < 4) - rack-session (2.1.1) + rack-session (2.1.2) base64 (>= 0.1.0) rack (>= 3.0.0) rack-test (2.2.0) rack (>= 1.3) - rackup (2.2.1) + rackup (2.3.1) rack (>= 3) - rails (8.0.3) - actioncable (= 8.0.3) - actionmailbox (= 8.0.3) - actionmailer (= 8.0.3) - actionpack (= 8.0.3) - actiontext (= 8.0.3) - actionview (= 8.0.3) - activejob (= 8.0.3) - activemodel (= 8.0.3) - activerecord (= 8.0.3) - activestorage (= 8.0.3) - activesupport (= 8.0.3) + rails (8.1.2.1) + actioncable (= 8.1.2.1) + actionmailbox (= 8.1.2.1) + actionmailer (= 8.1.2.1) + actionpack (= 8.1.2.1) + actiontext (= 8.1.2.1) + actionview (= 8.1.2.1) + activejob (= 8.1.2.1) + activemodel (= 8.1.2.1) + activerecord (= 8.1.2.1) + activestorage (= 8.1.2.1) + activesupport (= 8.1.2.1) bundler (>= 1.15.0) - railties (= 8.0.3) + railties (= 8.1.2.1) rails-controller-testing (1.0.5) actionpack (>= 5.0.1.rc1) actionview (>= 5.0.1.rc1) @@ -345,16 +358,16 @@ GEM rails-html-sanitizer (1.6.2) loofah (~> 2.21) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) - rails-i18n (8.0.2) + rails-i18n (8.1.0) i18n (>= 0.7, < 2) railties (>= 8.0.0, < 9) - rails_performance (1.4.2) + rails_performance (1.6.0) browser railties redis - railties (8.0.3) - actionpack (= 8.0.3) - activesupport (= 8.0.3) + railties (8.1.2.1) + actionpack (= 8.1.2.1) + activesupport (= 8.1.2.1) irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) @@ -363,24 +376,27 @@ GEM zeitwerk (~> 2.6) rainbow (3.1.1) raindrops (0.20.0) - rake (13.3.0) + rake (13.3.1) rb-fsevent (0.11.2) rb-inotify (0.10.1) ffi (~> 1.0) - rbs (3.9.5) + rbs (4.0.2) logger - rdoc (6.14.2) + prism (>= 1.6.0) + tsort + rdoc (7.2.0) erb psych (>= 4.0.0) + tsort redcarpet (3.6.1) redis (5.4.1) redis-client (>= 0.22.0) - redis-client (0.25.1) + redis-client (0.28.0) connection_pool redis-namespace (1.11.0) redis (>= 4) regexp_parser (2.9.0) - reline (0.6.2) + reline (0.6.3) io-console (~> 0.5) responders (3.1.1) actionpack (>= 5.2) @@ -396,7 +412,7 @@ GEM resque (>= 1.27) rufus-scheduler (~> 3.2, != 3.3) rexml (3.4.4) - rmagick (6.1.4) + rmagick (6.2.0) observer (~> 0.1) pkg-config (~> 1.4) rouge (4.1.3) @@ -404,25 +420,25 @@ GEM chunky_png (~> 1.0) rqrcode_core (~> 1.0) rqrcode_core (1.2.0) - rspec-core (3.13.5) + rspec-core (3.13.6) rspec-support (~> 3.13.0) rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-mocks (3.13.5) + rspec-mocks (3.13.8) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-rails (8.0.2) + rspec-rails (8.0.4) actionpack (>= 7.2) activesupport (>= 7.2) railties (>= 7.2) - rspec-core (~> 3.13) - rspec-expectations (~> 3.13) - rspec-mocks (~> 3.13) - rspec-support (~> 3.13) - rspec-support (3.13.5) + rspec-core (>= 3.13.0, < 5.0.0) + rspec-expectations (>= 3.13.0, < 5.0.0) + rspec-mocks (>= 3.13.0, < 5.0.0) + rspec-support (>= 3.13.0, < 5.0.0) + rspec-support (3.13.7) rtesseract (3.1.4) - ruby-lsp (0.26.2) + ruby-lsp (0.26.9) language_server-protocol (~> 3.17.0) prism (>= 1.2, < 2.0) rbs (>= 3, < 5) @@ -430,7 +446,7 @@ GEM ruby-progressbar (1.13.0) ruby-rc4 (0.1.5) ruby2_keywords (0.0.5) - rubyzip (3.0.2) + rubyzip (3.2.2) rufus-scheduler (3.9.2) fugit (~> 1.1, >= 1.11.1) rugged (1.9.0) @@ -444,8 +460,8 @@ GEM shoulda-callback-matchers (1.1.4) activesupport (>= 3) shoulda-context (3.0.0.rc1) - shoulda-matchers (6.5.0) - activesupport (>= 5.2.0) + shoulda-matchers (7.0.1) + activesupport (>= 7.1) simplecov (0.22.0) docile (~> 1.1) simplecov-html (~> 0.11) @@ -460,7 +476,7 @@ GEM rack-protection (= 4.2.0) rack-session (>= 2.0.0, < 3) tilt (~> 2.0) - sorbet-runtime (0.5.12059) + sorbet-runtime (0.6.12935) sprockets (4.2.2) concurrent-ruby (~> 1.0) logger @@ -469,15 +485,15 @@ GEM actionpack (>= 6.1) activesupport (>= 6.1) sprockets (>= 3.0.0) - stringio (3.1.7) + stringio (3.2.0) terminal-table (4.0.0) unicode-display_width (>= 1.1.1, < 4) terser (1.2.6) execjs (>= 0.3.0, < 3) - thor (1.4.0) + thor (1.5.0) tilt (2.6.1) timecop (0.9.10) - timeout (0.4.3) + timeout (0.6.1) tsort (0.2.0) ttfunk (1.8.0) bigdecimal (~> 3.1) @@ -490,7 +506,7 @@ GEM kgio (~> 2.6) raindrops (~> 0.7) uniform_notifier (1.18.0) - uri (1.1.0) + uri (1.1.1) useragent (0.16.11) webmock (3.26.1) addressable (>= 2.8.0) @@ -503,15 +519,17 @@ GEM websocket-extensions (0.1.5) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.7.3) + zeitwerk (2.7.5) PLATFORMS ruby DEPENDENCIES action_policy + active_record_doctor activejob-status activemodel-serializers-xml + annotaterb autoprefixer-rails awesome_print better_errors @@ -545,23 +563,24 @@ DEPENDENCIES marcel mini_mime pg + pghero pluck_to_hash prawn prawn-qrcode puma rack-cors - rails (~> 8.0.3) + rails (~> 8.1.2) rails-controller-testing rails-html-sanitizer - rails-i18n (~> 8.0.2) + rails-i18n (~> 8.1.0) rails_performance redcarpet redis (~> 5.4.1) responders resque resque-scheduler - rmagick (~> 6.1.4) - rspec-rails (~> 8.0.2) + rmagick (~> 6.2.0) + rspec-rails (~> 8.0.4) rtesseract ruby-lsp rubyzip @@ -569,7 +588,7 @@ DEPENDENCIES selenium-webdriver shoulda-callback-matchers (~> 1.1.1) shoulda-context (~> 3.0.0.rc1) - shoulda-matchers (~> 6.5) + shoulda-matchers (~> 7.0) simplecov simplecov-lcov sprockets diff --git a/app/MARKUS_VERSION b/app/MARKUS_VERSION index 516ff4ebb3..0b16d2484d 100644 --- a/app/MARKUS_VERSION +++ b/app/MARKUS_VERSION @@ -1 +1 @@ -VERSION=v2.9.6,PATCH_LEVEL=DEV +VERSION=v2.10.0,PATCH_LEVEL=DEV diff --git a/app/assets/javascripts/Annotations/pdf_annotation_manager.js b/app/assets/javascripts/Annotations/pdf_annotation_manager.js deleted file mode 100644 index e3334b727e..0000000000 --- a/app/assets/javascripts/Annotations/pdf_annotation_manager.js +++ /dev/null @@ -1,429 +0,0 @@ -(function (window) { - "use strict"; - - // CONSTANTS - const MOUSE_OFFSET = 10; // The offset from the mouse cursor point - const HIDE_BOX_THRESHOLD = 10; // Threshold for not displaying selection box in pixels - const COORDINATE_PRECISION = 5; // Keep 5 decimal places (used when converting back from ints) - const COORDINATE_MULTIPLIER = Math.pow(10, COORDINATE_PRECISION); - - /** - * AnnotationManager subclass for PDF files. - * - * @param {boolean} enable_annotations Whether annotations can be modified - */ - class PdfAnnotationManager extends AnnotationManager { - constructor(enable_annotations) { - // Members - super(); - this.angle = 0; // current orientation of the PDF - - this.annotationControls = {}; // DOM elements added for annotations - - /** @type {{page: int, $control: HTMLElement}} */ - this.selectionBox = {}; - - // The current selection area. The x, y are stored as percentages - // for device independent resolution - /** @type {{x: number, y: number, width: number, height: number}} */ - this.currentSelection = {}; - - // Event Handlers - if (enable_annotations) { - this.bindPageEvents(); - } - } - - /** - * Get a selection for a specific page. - * @return {{page: {int}, $control: {HTMLElement}}} - */ - getSelectionBox($page) { - let pageNumber = $page.data("page-number"); - - if ( - this.selectionBox.page === pageNumber && - this.selectionBox.$control !== null && - $.contains(document, this.selectionBox.$control) - ) { - return this.selectionBox; - } else if (this.selectionBox.$control != null) { - this.selectionBox.$control.remove(); // Remove old control - } - - let $control = document.createElement("div"); - $control.id = "sel_box"; - $control.addClass("annotation-holder-active"); - $control.style.display = "none"; - - // append $control before the first annotation_holder but after the annotationLayer - // or else you will be prevented from deleting/editing old annotations - let $first_anno = $page.find(".annotation_holder:first")[0]; - - if (!!$first_anno) { - $first_anno.parentNode.insertBefore($control, $first_anno); - } else { - $page.append($control); - } - - this.selectionBox = { - page: pageNumber, - $control: $control, - }; - - return this.selectionBox; - } - - /** - * Update and redraw the selection box. - * - * @param {jQuery} $page The page the control is for. - * @param {{x, y, width, height, visible}} params Values to update - */ - setSelectionBox($page, params) { - if (params) { - Object.assign(this.currentSelection, params); - - let sel_box = this.getSelectionBox($page).$control; - sel_box.style.top = this.currentSelection.y * 100 + "%"; - sel_box.style.left = this.currentSelection.x * 100 + "%"; - sel_box.style.width = this.currentSelection.width * 100 + "%"; - sel_box.style.height = this.currentSelection.height * 100 + "%"; - - sel_box.style.display = this.currentSelection.visible ? "block" : "none"; - } - } - - hide_selection_box() { - this.currentSelection.visible = false; - if (this.selectionBox.$control) { - this.selectionBox.$control.style.display = "none"; - } - } - - /** - * Returns the selection box coordinates (used when creating an annotation). - * @returns {{x1, y1, x2, y2, page}} - */ - getSelection(warn_no_selection = true) { - let box = this.get_pdf_box_attrs(); - if (!box) { - if (warn_no_selection) { - alert(I18n.t("results.annotation.select_an_area_pdf")); - } - return false; - } - let page = box.page; - box = getRotatedCoords(box, 360 - annotation_manager.angle); - - return {...box, page: page}; - } - - get_pdf_box_attrs() { - let box = this.selectionRectangleAsInts(); - let boxSize = this.selectionBoxSize(); - - if (!box || boxSize.width < HIDE_BOX_THRESHOLD || boxSize.height < HIDE_BOX_THRESHOLD) { - return false; - } else { - return box; - } - } - - /** - * Return the current selection bounding rectangle if there is one, and - * converts the floating point percentages to integers. - * - * @return {{x1, y1, x2, y2, page}} Bounding box (points are in percentages) - */ - selectionRectangleAsInts() { - if (!this.currentSelection.visible) { - return null; - } - - return { - x1: parseInt(this.currentSelection.x * COORDINATE_MULTIPLIER, 10), - y1: parseInt(this.currentSelection.y * COORDINATE_MULTIPLIER, 10), - x2: parseInt( - (this.currentSelection.x + this.currentSelection.width) * COORDINATE_MULTIPLIER, - 10 - ), - y2: parseInt( - (this.currentSelection.y + this.currentSelection.height) * COORDINATE_MULTIPLIER, - 10 - ), - page: this.selectionBox.page, - }; - } - - /** - * Get the selection box size in pixels. If there is no selection box - * return {width: 0, height: 0}. - * - * @return {{width: int, height: int}} The selection box size in pixels - */ - selectionBoxSize() { - let $box = this.selectionBox.$control; - - return $box ? {width: $box.offsetWidth, height: $box.offsetHeight} : {width: 0, height: 0}; - } - - /** - * Add an annotation to the PDF. - * - * @param {string} annotation_text_id [description] - * @param {string} content [description] - * @param {{x1: int, y1: int, x2: int, y2: int, page: int}} range - * @param {string} annotation_id the id of the annotation - * @param {boolean} is_remark - */ - addAnnotation(annotation_text_id, content, range, annotation_id, is_remark) { - let annotation = super.addAnnotation( - annotation_text_id, - content, - range, - annotation_id, - is_remark - ); - - // Update display - this.renderAnnotation(annotation); - this.hide_selection_box(); - } - - /** - * Remove an annotation - * @param {string} annotation_id Annotation ID - */ - removeAnnotation(annotation_id) { - let annotation = super.removeAnnotation(annotation_id); - if (annotation === undefined) { - return; - } - this.annotationControls[annotation_id].remove(); // Delete DOM node - } - - /** - * Draw the annotation on the screen. - * - * @param {{annotation_id: string, annotation_text: Object, range: Object}} annotation - */ - renderAnnotation = function (annotation) { - let {annotation_id, annotation_text, range} = annotation; - if (this.annotationControls[annotation_id]) { - this.annotationControls[annotation_id].remove(); // Remove old controls - } - - // The coords are in the unrotated form, but the PDF may be in a different orientation. - let newCoords = getRotatedCoords(range, this.angle); - - let $control = document.createElement("div"); - $control.id = "annotation_holder_" + annotation_id; - $control.addClass("annotation_holder"); - if (this.annotations[annotation_id].is_remark) { - $control.addClass("remark"); - } - $control.style.top = (newCoords.y1 / COORDINATE_MULTIPLIER) * 100 + "%"; - $control.style.left = (newCoords.x1 / COORDINATE_MULTIPLIER) * 100 + "%"; - $control.style.width = ((newCoords.x2 - newCoords.x1) / COORDINATE_MULTIPLIER) * 100 + "%"; - $control.style.height = ((newCoords.y2 - newCoords.y1) / COORDINATE_MULTIPLIER) * 100 + "%"; - - let annotation_text_displayer = this.annotation_text_displayer; - $control.onmousemove = function (ev) { - let point = getRelativePointForEvent(ev, $page, -1); - - annotation_text_displayer.setDisplayNodeParent($page[0]); - annotation_text_displayer.displayCollection( - [annotation_text], - point.x * 100, - point.y * 100, - "%" - ); - }; - - $control.onmouseleave = function () { - annotation_text_displayer.hide(); - }; - - this.annotationControls[annotation_id] = $control; - - let $page = $(`.page[data-page-number=${range.page}]`); - $page.append($control); - }; - - /** - * The following two functions are used to keep track of the orientation of - * the PDF so we know how to render the annotations. - */ - rotateClockwise90() { - this.hide_selection_box(); - this.angle += 90; - if (this.angle === 360) this.angle = 0; - } - - resetAngle() { - this.angle = 0; - } - - /** - * Set event handlers for PDF annotations. - */ - bindPageEvents() { - let $pages = $(".page"); - let selectionBoxActive = false; // Is the selection box in use - - // Start of click - let start = { - x: 0, - y: 0, - }; - - // Helper: Start selection box - const startSelection = (point, $target) => { - this.setSelectionBox($target, { - x: point.x, - y: point.y, - width: 0, - height: 0, - visible: true, - }); - - start = point; - selectionBoxActive = true; - }; - - // Helper: Update selection box dimensions - const updateSelection = (point, $target) => { - if (!selectionBoxActive) { - return; - } - - this.setSelectionBox($target, { - x: Math.min(start.x, point.x), - y: Math.min(start.y, point.y), - width: Math.abs(start.x - point.x), - height: Math.abs(start.y - point.y), - }); - }; - - // Helper: End selection box - const endSelection = () => { - let size = this.selectionBoxSize(); - - // If the box is REALLY small then hide it - if (size.width < HIDE_BOX_THRESHOLD && size.height < HIDE_BOX_THRESHOLD) { - this.hide_selection_box(); - } - - selectionBoxActive = false; - }; - - // Mouse event handlers - $pages.mousedown(ev => { - if (ev.which !== 1 && ev.target.id === "sel_box") { - return; - } - - let point = getRelativePointForEvent(ev); - startSelection(point, $(ev.delegateTarget)); - }); - - $pages.mousemove(ev => { - let point = getRelativePointForEvent(ev); - updateSelection(point, $(ev.delegateTarget)); - }); - - $pages.mouseup(() => { - endSelection(); - }); - - // Touch event handlers - $pages.on("touchstart", ev => { - // Prevent default to avoid scrolling while annotating - ev.preventDefault(); - - let touch = ev.originalEvent.touches[0]; - let point = getRelativePointForEvent(touch, ev.delegateTarget, undefined); - startSelection(point, $(ev.delegateTarget)); - }); - - $pages.on("touchmove", ev => { - // Prevent default to avoid scrolling while annotating - ev.preventDefault(); - - let touch = ev.originalEvent.touches[0]; - let point = getRelativePointForEvent(touch, ev.delegateTarget, undefined); - updateSelection(point, $(ev.delegateTarget)); - }); - - $pages.on("touchend", () => { - endSelection(); - }); - } - } - - /** - * Returns the selection point in percentage units for a mouse or touch event. - * - * @param {Event|Touch} eventOrTouch The event or touch object. - * @param {String|DOMNode|jQuery} relativeTo The element to calculate the offset for. - * @param {number} mouseOffset Custom mouse offset value. - * @return {{x: number, y:number}} The relative point in the element the event occurred in. - */ - function getRelativePointForEvent(eventOrTouch, relativeTo, mouseOffset) { - let $elem = relativeTo ? $(relativeTo) : $(eventOrTouch.delegateTarget); - let offset = $elem.offset(); - - let width = $elem.width(); - let height = $elem.height(); - - let x = eventOrTouch.pageX - offset.left - (mouseOffset || MOUSE_OFFSET); - let y = eventOrTouch.pageY - offset.top - (mouseOffset || MOUSE_OFFSET); - - return { - x: 1 - (width - x) / width, - y: 1 - (height - y) / height, - }; - } - - /** - * Returns the rotated coordinates of the annotation after applying - * the rotation specified by angle. - * - * @param {{x1: int, y1: int, x2: int, y2: int, page: int}} coords - * @param {number} angle - */ - function getRotatedCoords(coords, angle) { - let newCoords = { - x1: coords.x1, - x2: coords.x2, - y1: coords.y1, - y2: coords.y2, - }; - - switch (angle) { - case 90: - newCoords.x1 = COORDINATE_MULTIPLIER - coords.y2; - newCoords.x2 = COORDINATE_MULTIPLIER - coords.y1; - newCoords.y1 = coords.x1; - newCoords.y2 = coords.x2; - break; - case 180: - newCoords.x1 = COORDINATE_MULTIPLIER - coords.x2; - newCoords.x2 = COORDINATE_MULTIPLIER - coords.x1; - newCoords.y1 = COORDINATE_MULTIPLIER - coords.y2; - newCoords.y2 = COORDINATE_MULTIPLIER - coords.y1; - break; - case 270: - newCoords.x1 = coords.y1; - newCoords.x2 = coords.y2; - newCoords.y1 = COORDINATE_MULTIPLIER - coords.x2; - newCoords.y2 = COORDINATE_MULTIPLIER - coords.x1; - break; - } - return newCoords; - } - - // Exports - window.PdfAnnotationManager = PdfAnnotationManager; -})(window); diff --git a/app/assets/javascripts/Results/main.js b/app/assets/javascripts/Results/main.js index e843d7bd39..fad1d3c2b5 100644 --- a/app/assets/javascripts/Results/main.js +++ b/app/assets/javascripts/Results/main.js @@ -1,15 +1,3 @@ -// Source code highlighting -//= require Annotations/globals -//= require Annotations/annotation_manager -//= require Annotations/annotation_text -//= require Annotations/annotation_text_displayer -//= require Annotations/annotation_text_manager -//= require Annotations/source_code_line -//= require Annotations/text_annotation_manager -//= require Annotations/image_annotation_manager -//= require Annotations/pdf_annotation_manager -//= require Annotations/html_annotations - // Page-specific Javascript //= require Grader/marking //= require panes diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js deleted file mode 100644 index 0f65548139..0000000000 --- a/app/assets/javascripts/application.js +++ /dev/null @@ -1,35 +0,0 @@ -// This is a manifest file that'll be compiled into including all the files listed below. -// Add new JavaScript/Coffee code in separate files in this directory and they'll automatically -// be included in the compiled file accessible from http://example.com/assets/application.js -// It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the -// the compiled file. -// - -/** Helper functions for managing DOM elements' classes via pure JavaScript. */ - -Element.prototype.addClass = function (className) { - if (this.classList) { - this.classList.add(className); - } else { - this.className += " " + className; - } -}; - -Element.prototype.removeClass = function (className) { - if (this.classList) { - this.classList.remove(className); - } else { - this.className = this.className.replace( - new RegExp("(^|\\b)" + className.split(" ").join("|") + "(\\b|$)", "gi"), - " " - ); - } -}; - -Element.prototype.hasClass = function (className) { - if (this.classList) { - return this.classList.contains(className); - } else { - return new RegExp("(^| )" + className + "( |$)", "gi").test(this.className); - } -}; diff --git a/app/assets/stylesheets/clickable.scss b/app/assets/stylesheets/clickable.scss index f3c1be9b0f..0ad75d4824 100644 --- a/app/assets/stylesheets/clickable.scss +++ b/app/assets/stylesheets/clickable.scss @@ -1,4 +1,4 @@ -@import 'common/constants'; +@use 'common/constants' as *; .clickable_links { margin-left: 10px; diff --git a/app/assets/stylesheets/common/_courses.scss b/app/assets/stylesheets/common/_courses.scss index 5016b60171..b790758bbb 100644 --- a/app/assets/stylesheets/common/_courses.scss +++ b/app/assets/stylesheets/common/_courses.scss @@ -1,3 +1,6 @@ +@use 'constants' as *; +@use 'mixins'; + .course-list { display: flex; flex-flow: row wrap; @@ -5,6 +8,10 @@ width: 100%; } +.course-list > h2 { + flex-basis: 100%; +} + .course-card { border: 2px solid $sub-menu; border-radius: 5px; @@ -18,7 +25,7 @@ max-width: 300px; text-align: center; - @include breakpoint(mobile) { + @include mixins.breakpoint(mobile) { max-width: 100%; min-width: 300px; } diff --git a/app/assets/stylesheets/common/_criteria_filter.scss b/app/assets/stylesheets/common/_criteria_filter.scss index 3c20280aca..274498abe8 100644 --- a/app/assets/stylesheets/common/_criteria_filter.scss +++ b/app/assets/stylesheets/common/_criteria_filter.scss @@ -1,4 +1,4 @@ -@import 'constants'; +@use 'constants' as *; .criteria-filter { border-left: $disabled-area solid 1.5px; diff --git a/app/assets/stylesheets/common/_filter_modal.scss b/app/assets/stylesheets/common/_filter_modal.scss index 29d6902a84..baa15195f2 100644 --- a/app/assets/stylesheets/common/_filter_modal.scss +++ b/app/assets/stylesheets/common/_filter_modal.scss @@ -1,4 +1,4 @@ -@import 'constants'; +@use 'constants' as *; .filter-modal { .filter-modal-title { diff --git a/app/assets/stylesheets/common/_filter_modal_dropdown.scss b/app/assets/stylesheets/common/_filter_modal_dropdown.scss index 8e5672d6ac..13ce02194c 100644 --- a/app/assets/stylesheets/common/_filter_modal_dropdown.scss +++ b/app/assets/stylesheets/common/_filter_modal_dropdown.scss @@ -1,6 +1,6 @@ -@import 'constants'; +@use 'constants' as *; -@mixin filter-modal-dropdown { +@mixin styles { height: 30px; width: $dropdown-horizontal; diff --git a/app/assets/stylesheets/common/_icons.scss b/app/assets/stylesheets/common/_icons.scss index 71e4b02637..1233bc81c0 100644 --- a/app/assets/stylesheets/common/_icons.scss +++ b/app/assets/stylesheets/common/_icons.scss @@ -1,3 +1,6 @@ +@use 'constants' as *; +@use 'mixins'; + .icon-left { margin-right: 5px; } @@ -12,7 +15,7 @@ a[role='button'] > .svg-inline--fa { padding-right: 5px; vertical-align: baseline; - @include breakpoint(mobile) { + @include mixins.breakpoint(mobile) { padding-right: 0; } @@ -27,7 +30,7 @@ a[role='button'] > .fa-layers { margin-right: 5px; vertical-align: baseline; - @include breakpoint(mobile) { + @include mixins.breakpoint(mobile) { margin-right: 0; } diff --git a/app/assets/stylesheets/common/_login.scss b/app/assets/stylesheets/common/_login.scss index fc6d94eabf..9f0bd36f7d 100644 --- a/app/assets/stylesheets/common/_login.scss +++ b/app/assets/stylesheets/common/_login.scss @@ -1,5 +1,5 @@ -@import 'mixins'; -@import 'constants'; +@use 'mixins'; +@use 'constants' as *; /** * Login screen */ @@ -17,7 +17,7 @@ width: 500px; /* Mobile friendly! */ - @include breakpoint(tiny) { + @include mixins.breakpoint(tiny) { border-radius: 0; box-shadow: none; left: 0; diff --git a/app/assets/stylesheets/common/_markus.scss b/app/assets/stylesheets/common/_markus.scss index 5e1bea2043..f3403cf831 100644 --- a/app/assets/stylesheets/common/_markus.scss +++ b/app/assets/stylesheets/common/_markus.scss @@ -1,25 +1,25 @@ @charset "UTF-8"; -@import 'reset'; -@import 'constants'; -@import 'columns'; -@import 'file_viewer'; -@import 'login'; -@import 'mixins'; -@import 'modals'; -@import 'notes_dialog'; -@import 'annotations_dialog'; -@import 'icons'; -@import 'react_json_schema_form'; -@import 'react_tabs'; -@import 'courses'; -@import 'url_viewer'; -@import 'statistics'; -@import 'criteria_filter'; -@import 'multi_select_dropdown'; -@import 'single_select_dropdown'; -@import 'filter_modal'; -@import 'table'; +@use 'reset'; +@use 'constants' as *; +@use 'columns'; +@use 'file_viewer'; +@use 'login'; +@use 'mixins'; +@use 'modals'; +@use 'notes_dialog'; +@use 'annotations_dialog'; +@use 'icons'; +@use 'react_json_schema_form'; +@use 'react_tabs'; +@use 'courses'; +@use 'url_viewer'; +@use 'statistics'; +@use 'criteria_filter'; +@use 'multi_select_dropdown'; +@use 'single_select_dropdown'; +@use 'filter_modal'; +@use 'table'; /** Main */ @@ -29,7 +29,7 @@ body { font: 400 0.8em/1.2em $fonts; transition: background-color $time-quick; - @include breakpoint(tiny) { + @include mixins.breakpoint(tiny) { background-color: $background-main; } } @@ -227,6 +227,15 @@ textarea { } } +input[type='checkbox'] { + cursor: pointer; + + &[disabled], + &[readonly] { + cursor: default; + } +} + input { padding: 0.25em 0.75em; } @@ -1290,7 +1299,7 @@ nav { min-width: 100px; padding: 0 0.4em; - @include breakpoint(mobile) { + @include mixins.breakpoint(mobile) { min-width: 3em; > .svg-inline--fa:only-child { diff --git a/app/assets/stylesheets/common/_mixins.scss b/app/assets/stylesheets/common/_mixins.scss index 12de1800f9..d1af8a93ac 100644 --- a/app/assets/stylesheets/common/_mixins.scss +++ b/app/assets/stylesheets/common/_mixins.scss @@ -1,4 +1,6 @@ /** For easier size breakpoints. */ +@use 'sass:map'; +@use 'sass:meta'; $breakpoints: ( 'tiny': ( @@ -19,8 +21,8 @@ $breakpoints: ( ); @mixin breakpoint($name) { - @if map-has-key($breakpoints, $name) { - @media #{inspect(map-get($breakpoints, $name))} { + @if map.has-key($breakpoints, $name) { + @media #{meta.inspect(map.get($breakpoints, $name))} { @content; } } @else { diff --git a/app/assets/stylesheets/common/_modals.scss b/app/assets/stylesheets/common/_modals.scss index f731afe8a3..16664118ee 100644 --- a/app/assets/stylesheets/common/_modals.scss +++ b/app/assets/stylesheets/common/_modals.scss @@ -1,4 +1,4 @@ -@import 'constants'; +@use 'constants' as *; .react-modal { background: $background-main; diff --git a/app/assets/stylesheets/common/_multi_select_dropdown.scss b/app/assets/stylesheets/common/_multi_select_dropdown.scss index 9f8a78f199..773cbb207d 100644 --- a/app/assets/stylesheets/common/_multi_select_dropdown.scss +++ b/app/assets/stylesheets/common/_multi_select_dropdown.scss @@ -1,8 +1,8 @@ -@import 'constants'; -@import 'filter_modal_dropdown'; +@use 'constants' as *; +@use 'filter_modal_dropdown'; .dropdown.multi-select-dropdown { - @include filter-modal-dropdown; + @include filter_modal_dropdown.styles; padding: 0.25em; .tags-box { diff --git a/app/assets/stylesheets/common/_navigation.scss b/app/assets/stylesheets/common/_navigation.scss index d82f875e74..60b4dfa815 100644 --- a/app/assets/stylesheets/common/_navigation.scss +++ b/app/assets/stylesheets/common/_navigation.scss @@ -1,7 +1,8 @@ /** * Header, main menu, sub menu, and sub_sub menu. */ -@import 'constants'; +@use 'constants' as *; +@use 'mixins'; /* Header */ @@ -12,14 +13,14 @@ header#header { padding: 0.25em $dimen-horizontal-nav; width: 100%; - @include breakpoint(mobile) { + @include mixins.breakpoint(mobile) { border-bottom: 0; display: block; } #course, #user-info { - @include breakpoint(mobile) { + @include mixins.breakpoint(mobile) { display: block; } } @@ -63,7 +64,7 @@ nav { min-height: 38px; width: 90px; - @include breakpoint(mobile) { + @include mixins.breakpoint(mobile) { display: none; } } @@ -140,7 +141,7 @@ nav { width: 100%; z-index: 100000; - @include breakpoint(mobile) { + @include mixins.breakpoint(mobile) { display: block; } @@ -178,7 +179,7 @@ nav { #menus { background: $background-support; - @include breakpoint(mobile) { + @include mixins.breakpoint(mobile) { background: $sharp-line; height: 100%; overflow: hidden; @@ -193,7 +194,7 @@ nav { } #menus_child { - @include breakpoint(mobile) { + @include mixins.breakpoint(mobile) { background: $background-support; border-right: 1px solid $primary-three; height: 100%; @@ -236,7 +237,7 @@ nav { .main, .sub, .sub_sub { - @include breakpoint(mobile) { + @include mixins.breakpoint(mobile) { background: $background-main; border-bottom: 1px solid $primary-three; border-top: 1px solid $primary-three; @@ -246,7 +247,7 @@ nav { } li { - @include breakpoint(mobile) { + @include mixins.breakpoint(mobile) { border-radius: 0; text-align: left; width: 100%; @@ -257,7 +258,7 @@ nav { .main, .sub, .sub_sub { - @include breakpoint(mobile) { + @include mixins.breakpoint(mobile) { background: $background-main !important; border-bottom: 1px solid $primary-three !important; @@ -286,13 +287,13 @@ nav { /* Header */ #header { - @include breakpoint(mobile) { + @include mixins.breakpoint(mobile) { border: 0; text-align: left !important; } #course { - @include breakpoint(mobile) { + @include mixins.breakpoint(mobile) { display: block; float: none !important; padding: 1em; @@ -300,7 +301,7 @@ nav { } #course { - @include breakpoint(mobile) { + @include mixins.breakpoint(mobile) { border-bottom: 1px solid $primary-three; } } @@ -319,7 +320,7 @@ nav { } li#logo { - @include breakpoint(mobile) { + @include mixins.breakpoint(mobile) { display: none; } } @@ -327,13 +328,13 @@ li#logo { li#dropdown { margin: 0.4em 0.5em 0.5em 0; - @include breakpoint(mobile) { + @include mixins.breakpoint(mobile) { margin: 0 !important; } } .dropdown { - @include breakpoint(mobile) { + @include mixins.breakpoint(mobile) { border: 0; border-radius: 0; display: block; diff --git a/app/assets/stylesheets/common/_notes_dialog.scss b/app/assets/stylesheets/common/_notes_dialog.scss index 0966c0cea1..909348ea95 100644 --- a/app/assets/stylesheets/common/_notes_dialog.scss +++ b/app/assets/stylesheets/common/_notes_dialog.scss @@ -1,3 +1,5 @@ +@use 'constants' as *; + #notes_dialog { max-height: 75%; diff --git a/app/assets/stylesheets/common/_ocr_suggestions.scss b/app/assets/stylesheets/common/_ocr_suggestions.scss new file mode 100644 index 0000000000..f9ea2dc672 --- /dev/null +++ b/app/assets/stylesheets/common/_ocr_suggestions.scss @@ -0,0 +1,42 @@ +// OCR Suggestions Styles +// Used in assign_scans view for displaying OCR match data and student suggestions + +@use 'constants' as *; + +.ocr-suggestions-container { + margin: 1em 0; + padding: 1em; + background-color: $background-support; + border: 1px solid $gridline; + border-radius: var(--radius); + max-height: 400px; + overflow-y: auto; + overflow-x: hidden; + + code { + background-color: $disabled-area; + padding: 0.2em 0.4em; + border-radius: 3px; + } + + .no-match { + color: $disabled-text; + font-style: italic; + } +} + +.ocr-suggestions-list { + margin-top: 0.5em; + position: static; + + .ui-menu-item div:hover { + background-color: $primary-three; + color: $sharp-line; + } + + .student-info { + font-size: 1.1em; + color: $sharp-line; + font-weight: 500; + } +} diff --git a/app/assets/stylesheets/common/_react_json_schema_form.scss b/app/assets/stylesheets/common/_react_json_schema_form.scss index fda0b8d00e..810b968afa 100644 --- a/app/assets/stylesheets/common/_react_json_schema_form.scss +++ b/app/assets/stylesheets/common/_react_json_schema_form.scss @@ -1,6 +1,8 @@ // Custom styling for forms generated by react-json-schema-form. // Currently only used in the automated_tests/manage view. +@use 'constants' as *; + .rjsf { // Fieldsets and legends fieldset { @@ -39,7 +41,7 @@ } } - .array-item-add > .svg-inline--fa { + .rjsf-array-item-add > .svg-inline--fa { padding-right: 0; } @@ -47,7 +49,7 @@ min-width: 150px; } - .array-item { + .rjsf-array-item { border: 1px solid $primary-two; border-radius: $radius; display: flex; @@ -161,7 +163,7 @@ width: initial; } - .array-item { + .rjsf-array-item { border: 0; } } diff --git a/app/assets/stylesheets/common/_react_tabs.scss b/app/assets/stylesheets/common/_react_tabs.scss index fde07fb696..39986a824b 100644 --- a/app/assets/stylesheets/common/_react_tabs.scss +++ b/app/assets/stylesheets/common/_react_tabs.scss @@ -1,4 +1,4 @@ -@import 'constants'; +@use 'constants' as *; .react-tabs__tab.react-tabs__tab--disabled { display: none; diff --git a/app/assets/stylesheets/common/_reset.scss b/app/assets/stylesheets/common/_reset.scss index 4c41bcf4a2..91d936925c 100644 --- a/app/assets/stylesheets/common/_reset.scss +++ b/app/assets/stylesheets/common/_reset.scss @@ -3,7 +3,7 @@ /* ========================================================================== HTML5 display definitions ========================================================================== */ -@import 'constants'; +@use 'constants' as *; /* * Corrects `block` display not defined in IE 6/7/8/9 and Firefox 3. */ diff --git a/app/assets/stylesheets/common/_single_select_dropdown.scss b/app/assets/stylesheets/common/_single_select_dropdown.scss index 90422e23d4..d00364a362 100644 --- a/app/assets/stylesheets/common/_single_select_dropdown.scss +++ b/app/assets/stylesheets/common/_single_select_dropdown.scss @@ -1,8 +1,8 @@ -@import 'constants'; -@import 'filter_modal_dropdown'; +@use 'constants' as *; +@use 'filter_modal_dropdown'; .dropdown.single-select-dropdown { - @include filter-modal-dropdown; + @include filter_modal_dropdown.styles; a { display: inline-block; diff --git a/app/assets/stylesheets/common/_table.scss b/app/assets/stylesheets/common/_table.scss index 624f208e45..d5624d48c8 100644 --- a/app/assets/stylesheets/common/_table.scss +++ b/app/assets/stylesheets/common/_table.scss @@ -1,4 +1,5 @@ -@import 'constants'; +@use 'constants' as *; +@use 'mixins'; // These styles are partially derived from React Table v6. // https://github.com/TanStack/table/blob/v6/src/index.styl @@ -616,7 +617,7 @@ min-width: 100px; padding: 0 0.4em; - @include breakpoint(mobile) { + @include mixins.breakpoint(mobile) { min-width: 3em; } } @@ -650,3 +651,7 @@ padding: 10px 0; text-align: center; } + +.grid-wrapper { + justify-content: center; +} diff --git a/app/assets/stylesheets/common/_url_viewer.scss b/app/assets/stylesheets/common/_url_viewer.scss index ae39226ce7..eea217d513 100644 --- a/app/assets/stylesheets/common/_url_viewer.scss +++ b/app/assets/stylesheets/common/_url_viewer.scss @@ -1,3 +1,5 @@ +@use 'constants' as *; + .url-container { display: flex; flex-flow: column wrap; diff --git a/app/assets/stylesheets/common/codeviewer.scss b/app/assets/stylesheets/common/codeviewer.scss index 59924c6216..4519d7ce3a 100644 --- a/app/assets/stylesheets/common/codeviewer.scss +++ b/app/assets/stylesheets/common/codeviewer.scss @@ -1,5 +1,5 @@ -@import 'mixins'; -@import 'constants'; +@use 'mixins'; +@use 'constants' as *; .text-viewer-container { .toolbar { diff --git a/app/assets/stylesheets/common/core.scss b/app/assets/stylesheets/common/core.scss index e5d864ff6b..bcecb9b2ce 100644 --- a/app/assets/stylesheets/common/core.scss +++ b/app/assets/stylesheets/common/core.scss @@ -1,7 +1,9 @@ -@import 'markus'; -@import 'constants'; -@import 'navigation'; -@import '../../../../node_modules/@fortawesome/fontawesome-svg-core/styles'; +@use 'markus'; +@use 'constants' as *; +@use 'navigation'; +@use 'ocr_suggestions'; +@use 'mixins'; +@use '../../../../node_modules/@fortawesome/fontawesome-svg-core/styles'; #about_dialog { max-height: 75%; @@ -92,7 +94,7 @@ th.required::after { background: $background-main; padding: $dimen-vertical $dimen-horizontal; - @include breakpoint(mobile) { + @include mixins.breakpoint(mobile) { margin-top: 3.5em; padding: $dimen-vertical $dimen-hor-mobile; } @@ -139,7 +141,7 @@ th.required::after { .heading_buttons { display: table-cell; - @include breakpoint(mobile) { + @include mixins.breakpoint(mobile) { display: block; } } @@ -148,7 +150,7 @@ th.required::after { text-align: right; vertical-align: middle; - @include breakpoint(mobile) { + @include mixins.breakpoint(mobile) { text-align: left; } } @@ -268,7 +270,7 @@ th.required::after { word-break: break-all; } -@include breakpoint(mobile) { +@include mixins.breakpoint(mobile) { button, .button, .form-control { diff --git a/app/assets/stylesheets/common/jupyterlab-markus-custom.scss b/app/assets/stylesheets/common/jupyterlab-markus-custom.scss index 5a9320cc6b..4f7dacc625 100644 --- a/app/assets/stylesheets/common/jupyterlab-markus-custom.scss +++ b/app/assets/stylesheets/common/jupyterlab-markus-custom.scss @@ -1,5 +1,5 @@ /* MarkUs custom styling */ -@import 'constants'; +@use 'constants' as *; .markus-annotation { background-color: $light-alert; diff --git a/app/assets/stylesheets/common/pdfjs_custom.scss b/app/assets/stylesheets/common/pdfjs_custom.scss index 40b4a39c89..5bab50f247 100644 --- a/app/assets/stylesheets/common/pdfjs_custom.scss +++ b/app/assets/stylesheets/common/pdfjs_custom.scss @@ -1,3 +1,10 @@ +// pdfjs-dist v5 sets color-scheme: light dark on :root, which causes browsers +// to render native form controls (checkboxes, radios) in dark mode system styling. +// MarkUs manages its own dark theme manually, so we lock the scheme to light. +:root { + color-scheme: only light !important; +} + .pdfContainerParent { position: relative; overflow: auto; diff --git a/app/assets/stylesheets/context_menu.scss b/app/assets/stylesheets/context_menu.scss index cb43b8d62f..37fe971e36 100644 --- a/app/assets/stylesheets/context_menu.scss +++ b/app/assets/stylesheets/context_menu.scss @@ -1,4 +1,4 @@ -@import 'common/core'; +@use 'common/constants' as *; .ui-menu { background: $background-main; diff --git a/app/assets/stylesheets/entrypoints/rmd.scss b/app/assets/stylesheets/entrypoints/rmd.scss index e6fd46b24f..ecca8121c2 100644 --- a/app/assets/stylesheets/entrypoints/rmd.scss +++ b/app/assets/stylesheets/entrypoints/rmd.scss @@ -1,6 +1,6 @@ /* MarkUs custom styling */ @use 'fonts'; -@import '../common/constants'; +@use 'common/constants' as *; .markus-annotation { background-color: $light-alert; diff --git a/app/assets/stylesheets/grader.scss b/app/assets/stylesheets/grader.scss index 2a61dd3caa..1068376199 100644 --- a/app/assets/stylesheets/grader.scss +++ b/app/assets/stylesheets/grader.scss @@ -1,4 +1,5 @@ -@import 'common/core'; +@use 'common/constants' as *; +@use 'common/mixins'; /* Peer Review Styles */ @@ -207,7 +208,7 @@ display: flex; width: 100%; - @include breakpoint(small) { + @include mixins.breakpoint(small) { display: block; } } @@ -227,7 +228,7 @@ #right-pane { overflow: auto; - @include breakpoint(small) { + @include mixins.breakpoint(small) { display: block; float: none; width: 100% !important; @@ -238,7 +239,7 @@ margin-right: 3px; width: 70%; - @include breakpoint(small) { + @include mixins.breakpoint(small) { margin-bottom: 1em; padding-right: 0; } @@ -248,7 +249,7 @@ margin-left: 3px; width: 29.5%; - @include breakpoint(small) { + @include mixins.breakpoint(small) { padding-left: 0; } } @@ -259,7 +260,7 @@ position: inherit; width: 4px; - @include breakpoint(small) { + @include mixins.breakpoint(small) { display: none; } } diff --git a/app/controllers/admin/courses_controller.rb b/app/controllers/admin/courses_controller.rb index 4ded4cb437..00f6810b4c 100644 --- a/app/controllers/admin/courses_controller.rb +++ b/app/controllers/admin/courses_controller.rb @@ -60,6 +60,23 @@ def reset_autotest_connection respond_with current_course, location: -> { edit_admin_course_path(current_course) } end + def refresh_autotest_schema + settings = current_course.autotest_setting + if settings.nil? + flash_message(:error, I18n.t('automated_tests.no_autotest_settings')) + return respond_with current_course, location: -> { edit_admin_course_path(current_course) } + end + + begin + schema_json = get_schema(settings) + settings.update!(schema: schema_json) + flash_message(:success, I18n.t('automated_tests.manage_connection.refresh_schema_success')) + rescue StandardError => e + flash_message(:error, I18n.t('automated_tests.manage_connection.refresh_schema_failure', error: e.message)) + end + respond_with current_course, location: -> { edit_admin_course_path(current_course) } + end + def destroy_lti_deployment deployment = LtiDeployment.find(params[:lti_deployment_id]) deployment.destroy! @@ -69,7 +86,7 @@ def destroy_lti_deployment private def course_create_params - params.require(:course).permit(:name, :is_hidden, :display_name, :max_file_size) + params.require(:course).permit(:name, :is_hidden, :display_name, :max_file_size, :start_at, :end_at) end def course_update_params diff --git a/app/controllers/admin/main_admin_controller.rb b/app/controllers/admin/main_admin_controller.rb index 8ce2bf63ba..228a3e01fd 100644 --- a/app/controllers/admin/main_admin_controller.rb +++ b/app/controllers/admin/main_admin_controller.rb @@ -6,7 +6,10 @@ class MainAdminController < ApplicationController layout 'assignment_content' def index - @dashboards = [{ name: t('resque.dashboard'), path: admin_resque_path }] + @dashboards = [ + { name: t('resque.dashboard'), path: admin_resque_path }, + { name: t('pghero.dashboard'), path: admin_pghero_path } + ] if Settings.rails_performance.enabled @dashboards << { name: t('rails_performance.dashboard'), path: admin_performance_path } end diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 1d47b5a736..7ca3263070 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -9,7 +9,9 @@ class UsersController < ApplicationController def index respond_to do |format| format.html - format.json { render json: visible_users.order(:created_at).to_json(only: DEFAULT_FIELDS) } + format.json do + render json: visible_users.order(:created_at).pluck_to_hash(*DEFAULT_FIELDS) + end end end diff --git a/app/controllers/annotation_categories_controller.rb b/app/controllers/annotation_categories_controller.rb index d9ccb45739..77a8916bdf 100644 --- a/app/controllers/annotation_categories_controller.rb +++ b/app/controllers/annotation_categories_controller.rb @@ -170,7 +170,7 @@ def download disposition: 'attachment' else flash[:error] = t('download_errors.unrecognized_format', - format: params[:format]) + file_format: params[:format]) redirect_to action: 'index', id: params[:id] end diff --git a/app/controllers/api/grade_entry_forms_controller.rb b/app/controllers/api/grade_entry_forms_controller.rb index f4250ce488..dbc687864d 100644 --- a/app/controllers/api/grade_entry_forms_controller.rb +++ b/app/controllers/api/grade_entry_forms_controller.rb @@ -3,17 +3,32 @@ class GradeEntryFormsController < MainApiController DEFAULT_FIELDS = [:id, :short_identifier, :description, :due_date, :is_hidden, :visible_on, :visible_until, :show_total].freeze - # Sends the contents of the specified grade entry form + # Returns the specified grade entry form # Requires: id + # Default: returns CSV export (backward compatible) + # Optional: .json extension or Accept: application/json header for structured JSON with student grades + # user_names[] (filter to specific students) def show grade_entry_form = record - send_data grade_entry_form.export_as_csv(current_role), - type: 'text/csv', - filename: "#{grade_entry_form.short_identifier}_grades_report.csv", - disposition: 'inline' - rescue ActiveRecord::RecordNotFound => e - # could not find grade entry form - render 'shared/http_status', locals: { code: '404', message: e }, status: :not_found + if grade_entry_form.nil? + render 'shared/http_status', locals: { code: '404', message: + 'Grade Entry Form not found' }, status: :not_found + return + end + + user_names = params[:user_names].presence + + respond_to do |format| + format.json do + render json: grade_entry_form.export_as_json(current_role, user_names: user_names) + end + format.any do + send_data grade_entry_form.export_as_csv(current_role, user_names: user_names), + type: 'text/csv', + filename: "#{grade_entry_form.short_identifier}_grades_report.csv", + disposition: 'inline' + end + end end def index diff --git a/app/controllers/api/groups_controller.rb b/app/controllers/api/groups_controller.rb index 60a0a194ac..538f5e444d 100644 --- a/app/controllers/api/groups_controller.rb +++ b/app/controllers/api/groups_controller.rb @@ -187,9 +187,9 @@ def annotations 'annotations.y2 as y2'] annotations = grouping_relation.left_joins(current_submission_used: - [submission_files: - [annotations: - [annotation_text: :annotation_category]]]) + [{ submission_files: + [{ annotations: + [{ annotation_text: :annotation_category }] }] }]) .where(assessment_id: params[:assignment_id]) .where.not('annotations.id': nil) .pluck_to_hash(*pluck_keys) diff --git a/app/controllers/api/roles_controller.rb b/app/controllers/api/roles_controller.rb index db725c9da6..2d9f6cd2ee 100644 --- a/app/controllers/api/roles_controller.rb +++ b/app/controllers/api/roles_controller.rb @@ -137,15 +137,15 @@ def create_role def update_role(role) ApplicationRecord.transaction do - if params[:section_name] - if params[:section_name].empty? + if params.key?(:section_name) + if params[:section_name].blank? role.section = nil else role.section = @current_course.sections.find_by!(name: params[:section_name]) end end - role.grace_credits = params[:grace_credits] if params[:grace_credits] - role.hidden = params[:hidden].to_s.casecmp('true').zero? if params[:hidden] + role.grace_credits = params[:grace_credits] if params.key?(:grace_credits) + role.hidden = params[:hidden].to_s.casecmp('true').zero? if params.key?(:hidden) role.save! end render 'shared/http_status', locals: { code: '200', message: diff --git a/app/controllers/api/users_controller.rb b/app/controllers/api/users_controller.rb index 8cb6de3f7f..9e11effc58 100644 --- a/app/controllers/api/users_controller.rb +++ b/app/controllers/api/users_controller.rb @@ -12,7 +12,9 @@ def index respond_to do |format| format.xml { render xml: users.to_xml(only: DEFAULT_FIELDS, root: :users, skip_types: true) } - format.json { render json: users.to_json(only: DEFAULT_FIELDS) } + format.json do + render json: users.pluck_to_hash(*DEFAULT_FIELDS) + end end end diff --git a/app/controllers/assignments_controller.rb b/app/controllers/assignments_controller.rb index 29812c121d..9029566850 100644 --- a/app/controllers/assignments_controller.rb +++ b/app/controllers/assignments_controller.rb @@ -1074,10 +1074,12 @@ def assignment_params def duration_params params.require(:assignment).permit( assignment_properties_attributes: [ - duration: [ - :hours, - :minutes - ] + { + duration: [ + :hours, + :minutes + ] + } ] ) end diff --git a/app/controllers/automated_tests_controller.rb b/app/controllers/automated_tests_controller.rb index 5eaa251d4c..bf0870057c 100644 --- a/app/controllers/automated_tests_controller.rb +++ b/app/controllers/automated_tests_controller.rb @@ -155,8 +155,10 @@ def upload_files end begin - upload_files_helper(new_folders, new_files, unzip: unzip, - max_file_size: assignment.course.max_file_size) do |f| + upload_files_helper(new_folders, + new_files, + unzip: unzip, + max_file_size: assignment.course.max_file_size) do |f| if f.is_a?(String) # is a directory folder_path = FileHelper.checked_join(autotest_files_path, f) if folder_path.nil? diff --git a/app/controllers/courses_controller.rb b/app/controllers/courses_controller.rb index 857cf8704d..02bc4b98eb 100644 --- a/app/controllers/courses_controller.rb +++ b/app/controllers/courses_controller.rb @@ -128,8 +128,8 @@ def download_assignments type: 'text/yml', disposition: 'attachment') else - flash[:error] = t('download_errors.unrecognized_format', format: format) - redirect_back_or_to(course_assignments_path(current_course)) + flash[:error] = t('download_errors.unrecognized_format', file_format: format) + redirect_back(fallback_location: course_assignments_path(current_course)) end end diff --git a/app/controllers/grade_entry_forms_controller.rb b/app/controllers/grade_entry_forms_controller.rb index a5f4ab4ae5..3783127bda 100644 --- a/app/controllers/grade_entry_forms_controller.rb +++ b/app/controllers/grade_entry_forms_controller.rb @@ -1,7 +1,6 @@ # The actions necessary for managing grade entry forms. class GradeEntryFormsController < ApplicationController - include GradeEntryFormsHelper include RoutingHelper before_action { authorize! } @@ -307,4 +306,45 @@ def destroy flash_message(:error, t('grade_entry_forms.failed_deletion')) end end + + # Helper functions + private + + # Removes items that have empty names (so they don't get updated) + def update_grade_entry_form_params(attributes) + grade_entry_items = + params[:grade_entry_form][:grade_entry_items_attributes] + + unless grade_entry_items.nil? + # Delete items with empty name and out_of + grade_entry_items.delete_if { |_, item| item[:name].empty? && item[:out_of].empty? } + # Update the attributes hash + max_position = 1 + grade_entry_items.each_value do |item| + # Some items are being deleted so don't update those + unless item[:_destroy] == 1 + item[:position] = max_position + max_position += 1 + end + end + end + attributes[:grade_entry_items_attributes] = grade_entry_items + grade_entry_form_params(attributes) + end + + def grade_entry_form_params(attributes) + attributes.require(:grade_entry_form) + .permit(:description, + :message, + :due_date, + :show_total, + :short_identifier, + :is_hidden, + grade_entry_items_attributes: [:name, + :out_of, + :position, + :bonus, + :_destroy, + :id]) + end end diff --git a/app/controllers/graders_controller.rb b/app/controllers/graders_controller.rb index 2080faabce..6abbe339af 100644 --- a/app/controllers/graders_controller.rb +++ b/app/controllers/graders_controller.rb @@ -6,7 +6,7 @@ class GradersController < ApplicationController { ta_memberships: :role, inviter: :section }].freeze # The names of the associations of criteria required by the view, which # should be eagerly loaded. - CRITERION_ASSOC = [criterion_ta_associations: :ta].freeze + CRITERION_ASSOC = [{ criterion_ta_associations: :ta }].freeze before_action { authorize! } @@ -136,7 +136,6 @@ def global_actions if found_empty_submission assign_all_graders(filtered_grouping_ids, grader_ids) flash_now(:info, I18n.t('graders.group_submission_no_files')) - head :ok else assign_all_graders(grouping_ids, grader_ids) end @@ -192,6 +191,7 @@ def global_actions rescue StandardError => e head :bad_request flash_now(:error, e.message) + return end end when 'criteria_table' diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 12cb41fd2a..b73b3165a2 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -147,12 +147,18 @@ def assign_scans if num_valid == num_total flash_message(:success, t('exam_templates.assign_scans.done')) end + # Get OCR match data and suggestions if available + ocr_match = OcrMatchService.get_match(next_grouping.id) + ocr_suggestions = ocr_match ? OcrMatchService.get_suggestions(next_grouping.id, current_course.id) : [] + @data = { group_name: next_grouping.group.group_name, grouping_id: next_grouping.id, students: names, num_total: num_total, - num_valid: num_valid + num_valid: num_valid, + ocr_match: ocr_match, + ocr_suggestions: format_ocr_suggestions(ocr_suggestions) } next_file = next_grouping.current_submission_used.submission_files.find_by(filename: 'COVER.pdf') if next_file.nil? @@ -221,6 +227,8 @@ def assign_student_and_next end StudentMembership .find_or_create_by(role: student, grouping: @grouping, membership_status: StudentMembership::STATUSES[:inviter]) + # Clear OCR match data after successful assignment + OcrMatchService.clear_match(@grouping.id) end next_grouping end @@ -243,12 +251,18 @@ def next_grouping if num_valid == num_total flash_message(:success, t('exam_templates.assign_scans.done')) end + # Get OCR match data and suggestions if available + ocr_match = OcrMatchService.get_match(next_grouping.id) + ocr_suggestions = ocr_match ? OcrMatchService.get_suggestions(next_grouping.id, current_course.id) : [] + if !@grouping.nil? && next_grouping.id == @grouping.id render json: { grouping_id: next_grouping.id, students: names, num_total: num_total, - num_valid: num_valid + num_valid: num_valid, + ocr_match: ocr_match, + ocr_suggestions: format_ocr_suggestions(ocr_suggestions) } else data = { @@ -256,7 +270,9 @@ def next_grouping grouping_id: next_grouping.id, students: names, num_total: num_total, - num_valid: num_valid + num_valid: num_valid, + ocr_match: ocr_match, + ocr_suggestions: format_ocr_suggestions(ocr_suggestions) } next_file = next_grouping.current_submission_used.submission_files.find_by(filename: 'COVER.pdf') unless next_file.nil? @@ -290,6 +306,7 @@ def upload group_rows << row.compact_blank end + if result[:invalid_lines].empty? @current_job = CreateGroupsJob.perform_later assignment, group_rows session[:job_id] = @current_job.job_id @@ -473,8 +490,8 @@ def invite_member if errors.blank? to_invite.each do |i| i = i.strip - invited_user = current_course.students.joins(:user).where(hidden: false).find_by('users.user_name': i) - if invited_user.receives_invite_emails? + invited_user = current_course.students.joins(:user).find_by('users.user_name': i) + if invited_user&.receives_invite_emails? NotificationMailer.with(inviter: current_role, invited: invited_user, grouping: @grouping).grouping_invite_email.deliver_later @@ -683,9 +700,6 @@ def add_member(student, grouping, assignment) end @bad_user_names = [] - if student.hidden - raise I18n.t('groups.invite_member.errors.not_found', user_name: student.user_name) - end if student.has_accepted_grouping_for?(assignment.id) raise I18n.t('groups.invite_member.errors.already_grouped', user_name: student.user_name) end @@ -699,7 +713,7 @@ def add_member(student, grouping, assignment) # Generate a warning if a member is added to a group and they # have fewer grace days credits than already used by that group if student.remaining_grace_credits < grouping.grace_period_deduction_single - @warning_grace_day = I18n.t('groups.grace_day_over_limit', group: grouping.group.group_name) + flash_message(:warning, I18n.t('groups.grace_day_over_limit', group: grouping.group.group_name)) end grouping.reload @@ -728,6 +742,19 @@ def remove_member(membership, grouping) grouping.reload end + # Format OCR suggestions for JSON response + def format_ocr_suggestions(ocr_suggestions) + ocr_suggestions.map do |s| + { + id: s[:student].id, + user_name: s[:student].user.user_name, + id_number: s[:student].user.id_number, + display_name: s[:student].user.display_name, + similarity: (s[:similarity] * 100).round(1) + } + end + end + # This override is necessary because this controller is acting as a controller # for both groups and groupings. # diff --git a/app/controllers/lti_deployments_controller.rb b/app/controllers/lti_deployments_controller.rb index 94f9ed7c8d..aca2a6576c 100644 --- a/app/controllers/lti_deployments_controller.rb +++ b/app/controllers/lti_deployments_controller.rb @@ -90,8 +90,10 @@ def redirect_login client_id: lti_launch_data[:client_id], deployment_id: lti_params[LtiDeployment::LTI_CLAIMS[:deployment_id]], lms_course_name: lti_params[LtiDeployment::LTI_CLAIMS[:context]]['title'], + lms_term_name: lti_params[LtiDeployment::LTI_CLAIMS[:custom]]['term_name'], lms_course_label: lti_params[LtiDeployment::LTI_CLAIMS[:context]]['label'], lms_course_id: lti_params[LtiDeployment::LTI_CLAIMS[:custom]]['course_id'], + lms_course_sourcedid: lti_params[LtiDeployment::LTI_CLAIMS[:lis]]&.[]('course_offering_sourcedid'), user_roles: lti_params[LtiDeployment::LTI_CLAIMS[:roles]] } if lti_params.key?(LtiDeployment::LTI_CLAIMS[:rlid]) rlid = lti_params[LtiDeployment::LTI_CLAIMS[:rlid]]['id'] @@ -129,6 +131,8 @@ def redirect_login lms_course_id: lti_data[:lms_course_id]) lti_deployment.update!( lms_course_name: lti_data[:lms_course_name], + lms_course_sourcedid: lti_data[:lms_course_sourcedid], + lms_term_name: lti_data[:lms_term_name], resource_link_id: lti_data[:resource_link_id] ) session[:lti_course_label] = lti_data[:lms_course_label] @@ -213,8 +217,9 @@ def create_course return end - name = params['name'].gsub(/[^a-zA-Z0-9\-_]/, '-') # Sanitize name to comply with Course name validation - new_course = Course.find_or_initialize_by(name: name) + course_code = params['name'].gsub(/[^a-zA-Z0-9\-_]/, '-') # Sanitize name to comply with Course name validation + full_name = LtiConfig.get_course_name(record, course_code) + new_course = Course.find_or_initialize_by(name: full_name) unless new_course.new_record? flash_message(:error, I18n.t('lti.course_exists')) redirect_to choose_course_lti_deployment_path @@ -235,6 +240,13 @@ def get_config raise NotImplementedError end + # Define default URL options to not include locale + def default_url_options(_options = {}) + {} + end + + private + # Takes a string and returns a URI corresponding to the redirect # endpoint for the lms def construct_redirect_with_port(url, endpoint: nil) @@ -244,9 +256,4 @@ def construct_redirect_with_port(url, endpoint: nil) referer_host = referer_host_with_port if referer.to_s.start_with?(referer_host_with_port) URI("#{referer_host}#{endpoint}") end - - # Define default URL options to not include locale - def default_url_options(_options = {}) - {} - end end diff --git a/app/controllers/marks_graders_controller.rb b/app/controllers/marks_graders_controller.rb index edd11e5eac..2a150ecb00 100644 --- a/app/controllers/marks_graders_controller.rb +++ b/app/controllers/marks_graders_controller.rb @@ -86,7 +86,7 @@ def upload def grader_mapping grade_entry_form = GradeEntryForm.find(params[:grade_entry_form_id]) - students = Student.left_outer_joins(:user, grade_entry_students: [tas: :user]) + students = Student.left_outer_joins(:user, grade_entry_students: [{ tas: :user }]) .where('grade_entry_students.assessment_id': grade_entry_form.id) .order('users.user_name', 'users_roles.user_name') .pluck('users.user_name', 'users_roles.user_name') diff --git a/app/controllers/results_controller.rb b/app/controllers/results_controller.rb index f428298a5f..e79829db7a 100644 --- a/app/controllers/results_controller.rb +++ b/app/controllers/results_controller.rb @@ -239,7 +239,7 @@ def show data[:assignment_max_mark] = assignment.max_mark end data[:total] = marks_map.pluck('mark') - data[:old_total] = old_marks.values_at(:mark).compact.sum + data[:old_total] = original_result&.get_total_mark || 0 # Tags all_tags = assignment.tags.pluck_to_hash(:id, :name) @@ -454,7 +454,8 @@ def update_mark m_logger = MarkusLogger.instance - if result_mark.update(mark: mark_value, override: !(mark_value.nil? && result_mark.deductive_annotations_absent?)) + if result_mark.update(mark: mark_value, override: !(mark_value.nil? && result_mark.deductive_annotations_absent?), + last_updated_by: current_role) m_logger.log("User '#{current_role.user_name}' updated mark for " \ "submission (id: #{submission.id}) of " \ diff --git a/app/controllers/starter_file_groups_controller.rb b/app/controllers/starter_file_groups_controller.rb index d5b1e6c6bd..15eca5ac2e 100644 --- a/app/controllers/starter_file_groups_controller.rb +++ b/app/controllers/starter_file_groups_controller.rb @@ -50,8 +50,10 @@ def update_files end begin - upload_files_helper(new_folders, new_files, unzip: unzip, - max_file_size: assignment.course.max_file_size) do |f| + upload_files_helper(new_folders, + new_files, + unzip: unzip, + max_file_size: assignment.course.max_file_size) do |f| if f.is_a?(String) # is a directory folder_path = FileHelper.checked_join(target_path, f) if folder_path.nil? diff --git a/app/controllers/submissions_controller.rb b/app/controllers/submissions_controller.rb index f779bb43b1..8ee53a7e0e 100644 --- a/app/controllers/submissions_controller.rb +++ b/app/controllers/submissions_controller.rb @@ -441,8 +441,10 @@ def update_files messages.concat msgs end - upload_files_helper(new_folders, new_files, unzip: unzip, - max_file_size: @assignment.course.max_file_size) do |f| + upload_files_helper(new_folders, + new_files, + unzip: unzip, + max_file_size: @assignment.course.max_file_size) do |f| if f.is_a?(String) # is a directory authorize! to: :manage_subdirectories? # ensure user is authorized for directories in zip files success, msgs = add_folder(f, current_role, repo, path: path, txn: txn, required_files: required_files) diff --git a/app/helpers/assessments_helper.rb b/app/helpers/assessments_helper.rb new file mode 100644 index 0000000000..234a831e0c --- /dev/null +++ b/app/helpers/assessments_helper.rb @@ -0,0 +1,7 @@ +module AssessmentsHelper + def formatted_assessment_visibility_label(assessment, base_text) + return t('assignments.hidden', assignment_text: base_text) if assessment.currently_hidden? + + base_text + end +end diff --git a/app/helpers/grade_entry_forms_helper.rb b/app/helpers/grade_entry_forms_helper.rb deleted file mode 100644 index 4278f903ef..0000000000 --- a/app/helpers/grade_entry_forms_helper.rb +++ /dev/null @@ -1,43 +0,0 @@ -# Helper methods for grade entry forms - -module GradeEntryFormsHelper - # Removes items that have empty names (so they don't get updated) - def update_grade_entry_form_params(attributes) - grade_entry_items = - params[:grade_entry_form][:grade_entry_items_attributes] - - unless grade_entry_items.nil? - # Delete items with empty name and out_of - grade_entry_items.delete_if { |_, item| item[:name].empty? && item[:out_of].empty? } - # Update the attributes hash - max_position = 1 - grade_entry_items.each_value do |item| - # Some items are being deleted so don't update those - unless item[:_destroy] == 1 - item[:position] = max_position - max_position += 1 - end - end - end - attributes[:grade_entry_items_attributes] = grade_entry_items - grade_entry_form_params(attributes) - end - - private - - def grade_entry_form_params(attributes) - attributes.require(:grade_entry_form) - .permit(:description, - :message, - :due_date, - :show_total, - :short_identifier, - :is_hidden, - grade_entry_items_attributes: [:name, - :out_of, - :position, - :bonus, - :_destroy, - :id]) - end -end diff --git a/app/helpers/lti_helper.rb b/app/helpers/lti_helper.rb index 8a434f1987..92ce231787 100644 --- a/app/helpers/lti_helper.rb +++ b/app/helpers/lti_helper.rb @@ -34,7 +34,7 @@ def roster_sync(lti_deployment, role_types, can_create_users: false, can_create_ lms_active_data = [] lms_inactive_ids = [] member_info.each do |user| - next if user['roles'].include?(LtiDeployment::LTI_ROLES['test_user']) + next if user['roles'].include?(LtiDeployment::LTI_ROLES[:test_user]) next if role_types.none? { |role| user['roles'].include?(role) } if user['status'] == LtiDeployment::LTI_STATUSES[:inactive] lms_inactive_ids << user['user_id'] @@ -190,6 +190,7 @@ def create_or_update_lti_assessment(lti_deployment, assessment) resourceId: assessment.short_identifier, scoreMaximum: assessment.max_mark.to_f } + payload[:endDateTime] = assessment.due_date.iso8601 if assessment.due_date.present? auth_data = lti_deployment.lti_client.get_oauth_token([LtiDeployment::LTI_SCOPES[:ags_lineitem]]) lineitem_service = lti_deployment.lti_services.find_by!(service_type: 'agslineitem') lineitem_uri = URI(lineitem_service.url) @@ -199,7 +200,8 @@ def create_or_update_lti_assessment(lti_deployment, assessment) else req = Net::HTTP::Post.new(lineitem_uri) end - req.set_form_data(payload) + req.content_type = 'application/vnd.ims.lis.v2.lineitem+json' + req.body = payload.to_json res = lti_deployment.send_lti_request!(req, lineitem_uri, auth_data, [LtiDeployment::LTI_SCOPES[:ags_lineitem]]) line_item_data = JSON.parse(res.body) line_item.update!(lti_line_item_id: line_item_data['id']) diff --git a/app/helpers/upload_helper.rb b/app/helpers/upload_helper.rb index 75e954c3dd..9d4dabab09 100644 --- a/app/helpers/upload_helper.rb +++ b/app/helpers/upload_helper.rb @@ -61,8 +61,11 @@ def upload_files_helper(new_folders, new_files, unzip: false, entry_size = zf.size if entry_size && max_file_size && entry_size > max_file_size max_mb = (max_file_size / 1_000_000.0).round(2) - raise StandardError, I18n.t('upload_errors.zip_entry_too_large', - file_name: zf.name, max_size: max_mb) + raise StandardError, I18n.t( + 'upload_errors.zip_entry_too_large', + file_name: zf.name, + max_size: max_mb + ) end if entry_size && max_zip_total_size total_size += entry_size @@ -84,8 +87,11 @@ def upload_files_helper(new_folders, new_files, unzip: false, streamed_size += chunk.bytesize if max_file_size && streamed_size > max_file_size max_mb = (max_file_size / 1_000_000.0).round(2) - raise StandardError, I18n.t('upload_errors.zip_entry_too_large', - file_name: zf.name, max_size: max_mb) + raise StandardError, I18n.t( + 'upload_errors.zip_entry_too_large', + file_name: zf.name, + max_size: max_mb + ) end if entry_size.nil? && max_zip_total_size && total_size + streamed_size > max_zip_total_size max_mb = (max_zip_total_size / 1_000_000.0).round(2) diff --git a/app/javascript/Components/Assessment_Chart/grade_breakdown_chart.jsx b/app/javascript/Components/Assessment_Chart/grade_breakdown_chart.jsx index f56f6eaf9a..3c72450b9a 100644 --- a/app/javascript/Components/Assessment_Chart/grade_breakdown_chart.jsx +++ b/app/javascript/Components/Assessment_Chart/grade_breakdown_chart.jsx @@ -1,41 +1,50 @@ import React from "react"; import {Bar} from "react-chartjs-2"; import {chartScales} from "../Helpers/chart_helpers"; -import ReactTable from "react-table"; +import Table from "../table/table"; +import {createColumnHelper} from "@tanstack/react-table"; import PropTypes from "prop-types"; import {CoreStatistics} from "./core_statistics"; import {FractionStat} from "./fraction_stat"; +const columnHelper = createColumnHelper(); + export class GradeBreakdownChart extends React.Component { render() { + const columns = [ + columnHelper.accessor("position", { + id: "position", + enableColumnFilter: false, + }), + columnHelper.accessor("name", { + header: this.props.item_name, + minSize: 150, + enableColumnFilter: false, + }), + columnHelper.accessor("average", { + header: I18n.t("average"), + enableColumnFilter: false, + cell: info => ( + + ), + }), + ]; let summary_table = ""; if (this.props.show_stats) { summary_table = (
- ( - - ), - }, - ]} - defaultSorted={[{id: "position"}]} - SubComponent={row => ( + columns={columns} + initialState={{ + columnVisibility: {position: false}, + }} + getRowCanExpand={() => true} + renderSubComponent={({row}) => (
this.props.groupNameWithMembers(row), + minWidth: 250, + filterMethod: (filter, row) => this.props.groupNameFilter(filter, row), + }, + { + Header: I18n.t("submissions.release_token"), + id: "result_view_token", + filterable: false, + sortable: false, + minWidth: 250, + Cell: row => { + return ( + + ); + }, + }, + { + Header: I18n.t("submissions.release_token_expires"), + id: "result_view_token_expiry", + filterable: false, + sortable: false, + minWidth: 200, + Cell: row => { + return ( + + this.refreshViewTokenExpiry(selectedDates[0], [row.original.result_id]) + } + options={{ + altInput: true, + altFormat: I18n.t("time.format_string.flatpickr"), + dateFormat: "Z", + }} + /> + ); + }, + }, + ], + }; } componentDidMount() { @@ -96,65 +157,7 @@ class ReleaseUrlsModal extends React.Component { filterable defaultSorted={[{id: "group_name"}]} loading={this.state.loading} - columns={[ - { - show: false, - accessor: "_id", - id: "_id", - }, - { - Header: I18n.t("activerecord.models.group.one"), - accessor: "group_name", - id: "group_name", - Cell: this.props.groupNameWithMembers, - minWidth: 250, - filterMethod: this.props.groupNameFilter, - }, - { - Header: I18n.t("submissions.release_token"), - id: "result_view_token", - filterable: false, - sortable: false, - minWidth: 250, - Cell: row => { - return ( - - ); - }, - }, - { - Header: I18n.t("submissions.release_token_expires"), - id: "result_view_token_expiry", - filterable: false, - sortable: false, - minWidth: 200, - Cell: row => { - return ( - - this.refreshViewTokenExpiry(selectedDates[0], [row.original.result_id]) - } - options={{ - altInput: true, - altFormat: I18n.t("time.format_string.flatpickr"), - dateFormat: "Z", - }} - /> - ); - }, - }, - ]} + columns={this.state.columns} SubComponent={row => { if (row.original.result_view_token) { const url = Routes.view_marks_course_result_url( diff --git a/app/javascript/Components/Result/annotation_table.jsx b/app/javascript/Components/Result/annotation_table.jsx index 6d1e3a1da7..58c6408cb8 100644 --- a/app/javascript/Components/Result/annotation_table.jsx +++ b/app/javascript/Components/Result/annotation_table.jsx @@ -8,6 +8,21 @@ export class AnnotationTable extends React.Component { constructor(props) { super(props); this.annotationTable = React.createRef(); + this.state = { + columns: this.getColumns( + props.detailed, + props.annotations.some(a => !!a.deduction) + ), + }; + } + + componentDidUpdate(prevProps) { + renderMathInElement(this.annotationTable.current); + const hadDeductions = prevProps.annotations.some(a => !!a.deduction); + const hasDeductions = this.props.annotations.some(a => !!a.deduction); + if (prevProps.detailed !== this.props.detailed || hadDeductions !== hasDeductions) { + this.setState({columns: this.getColumns(this.props.detailed, hasDeductions)}); + } } deductionFilter = (filter, row) => { @@ -168,29 +183,23 @@ export class AnnotationTable extends React.Component { maxWidth: 120, }; - componentDidMount() { - renderMathInElement(this.annotationTable.current); - } + getColumns = (detailed, hasDeductions) => { + const columns = detailed ? [...this.columns, ...this.detailedColumns] : [...this.columns]; + if (hasDeductions) columns.push(this.deductionColumn); + return columns; + }; - componentDidUpdate() { + componentDidMount() { renderMathInElement(this.annotationTable.current); } render() { - let allColumns = this.columns; - if (this.props.detailed) { - allColumns = allColumns.concat(this.detailedColumns); - } - if (this.props.annotations.some(a => !!a.deduction)) { - allColumns.push(this.deductionColumn); - } - return (
{ return {className: "-wrap"}; }} diff --git a/app/javascript/Components/Result/context_menu.js b/app/javascript/Components/Result/context_menu.js index 1563f2edaf..cb74e26621 100644 --- a/app/javascript/Components/Result/context_menu.js +++ b/app/javascript/Components/Result/context_menu.js @@ -94,7 +94,7 @@ export var annotation_context_menu = { }; function get_annotation_id(clicked_element) { - if (annotation_type === ANNOTATION_TYPES.CODE) { + if (window.annotation_type === window.ANNOTATION_TYPES.CODE) { let curr = clicked_element; while (curr !== null && curr.tagName === "SPAN") { for (let attr in curr.dataset) { @@ -153,7 +153,7 @@ export var annotation_context_menu = { // Enable "delete" menu item if an annotation was clicked. var annotation_selected = (function () { var clicked_element = $(ui.target); - if (annotation_type === ANNOTATION_TYPES.CODE) { + if (window.annotation_type === window.ANNOTATION_TYPES.CODE) { return clicked_element.closest(".source-code-glowing-1").length > 0; } else { return clicked_element.closest(".annotation_holder").length > 0; diff --git a/app/javascript/Components/Result/html_viewer.jsx b/app/javascript/Components/Result/html_viewer.jsx index e065326bef..ff887d6572 100644 --- a/app/javascript/Components/Result/html_viewer.jsx +++ b/app/javascript/Components/Result/html_viewer.jsx @@ -15,7 +15,7 @@ export class HTMLViewer extends React.PureComponent { } readyAnnotations = () => { - annotation_type = ANNOTATION_TYPES.HTML; + window.annotation_type = window.ANNOTATION_TYPES.HTML; }; renderAnnotations = () => { diff --git a/app/javascript/Components/Result/image_viewer.jsx b/app/javascript/Components/Result/image_viewer.jsx index 23a835b64b..13630a69c1 100644 --- a/app/javascript/Components/Result/image_viewer.jsx +++ b/app/javascript/Components/Result/image_viewer.jsx @@ -1,5 +1,5 @@ import React from "react"; -import heic2any from "heic2any"; +import {ImageAnnotationManager} from "../../common/annotations/image_annotation_manager"; export class ImageViewer extends React.PureComponent { constructor(props) { @@ -40,7 +40,9 @@ export class ImageViewer extends React.PureComponent { // Returns a promise containing an object URL for a JPEG image converted from the HEIC/HEIF format. return fetch(this.props.url) .then(res => res.blob()) - .then(blob => heic2any({blob, toType: "image/jpeg"})) + .then(blob => + import("heic2any").then(({default: heic2any}) => heic2any({blob, toType: "image/jpeg"})) + ) .then(conversionResult => URL.createObjectURL(conversionResult)) .then(JPEGObjectURL => this.setState({url: JPEGObjectURL})); } else { @@ -56,7 +58,7 @@ export class ImageViewer extends React.PureComponent { }; ready_annotations = () => { - window.annotation_type = ANNOTATION_TYPES.IMAGE; + window.annotation_type = window.ANNOTATION_TYPES.IMAGE; $(".annotation_holder").remove(); window.annotation_manager = new ImageAnnotationManager(!this.props.released_to_students); @@ -188,10 +190,10 @@ export class ImageViewer extends React.PureComponent { let picture = document.getElementById("image_preview"); if (this.state.rotation > 0) { - picture.addClass("rotate" + this.state.rotation.toString()); - picture.removeClass("rotate" + (this.state.rotation - 90).toString()); + picture.classList.add("rotate" + this.state.rotation.toString()); + picture.classList.remove("rotate" + (this.state.rotation - 90).toString()); } else { - picture.removeClass("rotate270"); + picture.classList.remove("rotate270"); } }; diff --git a/app/javascript/Components/Result/pdf_viewer.jsx b/app/javascript/Components/Result/pdf_viewer.jsx index b0b05f1f64..952b01df15 100644 --- a/app/javascript/Components/Result/pdf_viewer.jsx +++ b/app/javascript/Components/Result/pdf_viewer.jsx @@ -1,5 +1,6 @@ import React from "react"; import {SingleSelectDropDown} from "../DropDown/SingleSelectDropDown"; +import {PdfAnnotationManager} from "../../common/annotations/pdf_annotation_manager"; export class PDFViewer extends React.PureComponent { constructor(props) { @@ -16,7 +17,6 @@ export class PDFViewer extends React.PureComponent { this.pdfViewer = new pdfjsViewer.PDFViewer({ eventBus: this.eventBus, container: this.pdfContainer.current, - // renderer: 'svg', TODO: investigate why some fonts don't render with SVG }); window.pdfViewer = this; // For fixing display when pane width changes @@ -51,7 +51,7 @@ export class PDFViewer extends React.PureComponent { }; ready_annotations = () => { - annotation_type = ANNOTATION_TYPES.PDF; + window.annotation_type = window.ANNOTATION_TYPES.PDF; window.annotation_manager = new PdfAnnotationManager(!this.props.released_to_students); window.annotation_manager.resetAngle(); diff --git a/app/javascript/Components/Result/result.jsx b/app/javascript/Components/Result/result.jsx index 16d60603e5..fa02a9aca1 100644 --- a/app/javascript/Components/Result/result.jsx +++ b/app/javascript/Components/Result/result.jsx @@ -13,6 +13,8 @@ import CreateTagModal from "../Modals/create_tag_modal"; import {pathToNode} from "../Helpers/range_selector"; import {ResultContext} from "./result_context"; import {annotation_context_menu} from "./context_menu"; +import {AnnotationText} from "../../common/annotations/annotation_text"; +import {get_html_annotation_range} from "../../common/annotations/html_annotations"; const INITIAL_ANNOTATION_MODAL_STATE = { show: false, @@ -336,7 +338,7 @@ class Result extends React.Component { extend_with_selection_data = annotation_data => { let box; - if (annotation_type === ANNOTATION_TYPES.HTML) { + if (window.annotation_type === window.ANNOTATION_TYPES.HTML) { const range = get_html_annotation_range(); box = { start_node: pathToNode(range.startContainer), diff --git a/app/javascript/Components/Result/submission_selector.jsx b/app/javascript/Components/Result/submission_selector.jsx index 809660d3f1..a531e5c856 100644 --- a/app/javascript/Components/Result/submission_selector.jsx +++ b/app/javascript/Components/Result/submission_selector.jsx @@ -57,7 +57,7 @@ export class SubmissionSelector extends React.Component { buttonText = I18n.t("submissions.unrelease_marks"); disabled = false; icon = ( - + [ + criterionColumns = remark_submitted => [ { Header: I18n.t("activerecord.models.criterion.one"), accessor: "criterion", @@ -86,7 +97,7 @@ export class SummaryPanel extends React.Component { Header: "Old Mark", accessor: "old_mark.mark", className: "number", - show: this.props.remark_submitted, + show: remark_submitted, }, { Header: I18n.t("activerecord.models.mark.one"), @@ -136,7 +147,7 @@ export class SummaryPanel extends React.Component { ); }; - extraMarksColumns = () => [ + extraMarksColumns = released_to_students => [ { Header: I18n.t("activerecord.attributes.extra_mark.description"), accessor: "description", @@ -172,7 +183,7 @@ export class SummaryPanel extends React.Component { { Header: "", id: "action", - show: !this.props.released_to_students, + show: !released_to_students, Cell: row => { if (row.original._new) { return ( @@ -233,7 +244,7 @@ export class SummaryPanel extends React.Component { return (

{I18n.t("activerecord.models.extra_mark.other")}

- {data.length > 0 && } + {data.length > 0 && } {!this.props.released_to_students && (