diff --git a/.github/workflows/brakeman.yml b/.github/workflows/brakeman.yml new file mode 100644 index 0000000..88b43bf --- /dev/null +++ b/.github/workflows/brakeman.yml @@ -0,0 +1,21 @@ +name: Brakeman + +on: + pull_request: + branches: + master + +jobs: + brakeman: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + # Will run Brakeman checks on dependencies + # https://github.com/marketplace/actions/brakeman-linter + - name: Brakeman + uses: devmasx/brakeman-linter-action@v1.0.0 + env: + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} diff --git a/.github/workflows/eslint.yml b/.github/workflows/eslint.yml new file mode 100644 index 0000000..8f043ff --- /dev/null +++ b/.github/workflows/eslint.yml @@ -0,0 +1,18 @@ +name: ESLint + +on: [pull_request] + +jobs: + eslint: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + # Will run ES Lint checks on javascript files + # https://github.com/marketplace/actions/run-eslint + - name: 'ES Lint checks' + uses: stefanoeb/eslint-action@1.0.0 + with: + args: './app/javascript/**/*.js' diff --git a/.github/workflows/mysql.yml b/.github/workflows/mysql.yml new file mode 100644 index 0000000..dbacba0 --- /dev/null +++ b/.github/workflows/mysql.yml @@ -0,0 +1,113 @@ +name: Run Tests (mySQL) + +on: [pull_request] + +jobs: + mysql: + runs-on: ubuntu-latest + + env: + DB_ADAPTER: mysql2 + MYSQL_PWD: root + RAILS_ENV: test + + steps: + # Checkout the repo + - uses: actions/checkout@v2 + with: + fetch-depth: 1 + + # Install the necessary MySQL dev packages + - name: 'Install Mysql Packages' + run: | + sudo apt-get update + sudo apt-get install -y mysql-client libmysqlclient-dev + + # Extract the Ruby version from the Gemfile.lock + - name: 'Determine Ruby Version' + run: echo ::set-env name=RUBY_VERSION::$(echo `cat ./Gemfile.lock | grep -A 1 'RUBY VERSION' | grep 'ruby' | grep -oE '[0-9]\.[0-9]'`) + + # Install Ruby - using the version found in the Gemfile.lock + - name: 'Install Ruby' + uses: actions/setup-ruby@v1 + with: + ruby-version: ${{ env.RUBY_VERSION }} + + # Copy all of the example configs over + - name: 'Setup Default Configuration' + run: | + # Make copies of all the example config files + cp config/branding.yml.sample config/branding.yml + cp config/database.yml.sample config/database.yml + cp config/secrets.yml.sample config/secrets.yml + cp config/initializers/contact_us.rb.example config/initializers/contact_us.rb + cp config/initializers/devise.rb.example config/initializers/devise.rb + cp config/initializers/recaptcha.rb.example config/initializers/recaptcha.rb + cp config/initializers/wicked_pdf.rb.example config/initializers/wicked_pdf.rb + + # Try to retrieve the gems from the cache + - name: 'Cache Gems' + uses: actions/cache@v1 + with: + path: vendor/bundle + key: ${{ runner.os }}-gem-${{ hashFiles('**/Gemfile.lock') }} + restore-keys: | + ${{ runner.os }}-gem- + + # Install bundler and run bundle install + - name: 'Bundle Install' + run: | + gem install bundler -v 1.17.2 + bundle config path vendor/bundle + bundle install --jobs 4 --retry 3 --without pgsql rollbar aws + + # Try to retrieve the yarn JS dependencies from the cache + - name: 'Cache Yarn Packages' + uses: actions/cache@v1 + with: + path: node_modules/ + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-build-${{ env.cache-name }}- + ${{ runner.os }}-build- + ${{ runner.os }}-yarn- + ${{ runner.os }}- + + # Figure out where wkhtmltopdf is installed + - name: 'Determine wkhtmltopdf location' + run: echo ::set-env name=WICKED_PDF_PATH::$(echo `bundle exec which wkhtmltopdf`) + + # Startup MySQL + - name: 'Start MySQL' + run: sudo systemctl start mysql + + + + # Install the JS dependencies + - name: 'Yarn Install' + run: | + yarn install + + # Figure out where wkhtmltopdf is installed + - name: 'Determine wkhtmltopdf location' + run: echo ::set-env name=WICKED_PDF_PATH::$(echo `bundle exec which wkhtmltopdf`) + + # Setup the database + - name: 'Setup Test DB' + run: bundle exec rake db:setup RAILS_ENV=test + + # Compile the assets + - name: 'Compile Assets' + run: | + bundle exec rake webpacker:compile RAILS_ENV=test + bundle exec rake assets:precompile RAILS_ENV=test + + # Run the JS tests + - name: 'Run Karma Tests' + run: | + yarn add karma + yarn run test + + # Run the Rspec tests + - name: 'Run Rspec Tests' + run: bundle exec rspec spec/ diff --git a/.github/workflows/postgres.yml b/.github/workflows/postgres.yml new file mode 100644 index 0000000..9fa69ff --- /dev/null +++ b/.github/workflows/postgres.yml @@ -0,0 +1,120 @@ +name: Run Tests (postgreSQL) + +on: [pull_request] + +jobs: + postgresql: + runs-on: ubuntu-latest + + services: + # Postgres installation + db: + image: postgres + env: + # Latest version of Postgres has increased security. We can use the default + # user/password in this testing scenario though so use the following env + # variable to bypass this changes: + # https://github.com/docker-library/postgres/issues/681 + POSTGRES_HOST_AUTH_METHOD: trust + ports: ['5432:5432'] + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + RAILS_ENV: test + DATABASE_URL: postgres://postgres:@localhost:5432/roadmap_test + + steps: + # Checkout the repo + - uses: actions/checkout@v2 + with: + fetch-depth: 1 + + # Install the necessary Postgres dev packages + - name: 'Install Postgresql Packages' + run: | + sudo apt-get update + sudo apt-get install libpq-dev + + # Extract the Ruby version from the Gemfile.lock + - name: 'Determine Ruby Version' + run: echo ::set-env name=RUBY_VERSION::$(echo `cat ./Gemfile.lock | grep -A 1 'RUBY VERSION' | grep 'ruby' | grep -oE '[0-9]\.[0-9]'`) + + + # Install Ruby - using the version found in the Gemfile.lock + - name: 'Install Ruby' + uses: actions/setup-ruby@v1 + with: + ruby-version: ${{ env.RUBY_VERSION }} + + # Copy all of the example configs over + - name: 'Setup Default Configuration' + run: | + # Make copies of all the example config files + cp config/branding.yml.sample config/branding.yml + cp config/database.yml.sample config/database.yml + cp config/secrets.yml.sample config/secrets.yml + cp config/initializers/contact_us.rb.example config/initializers/contact_us.rb + cp config/initializers/devise.rb.example config/initializers/devise.rb + cp config/initializers/recaptcha.rb.example config/initializers/recaptcha.rb + cp config/initializers/wicked_pdf.rb.example config/initializers/wicked_pdf.rb + + # Try to retrieve the gems from the cache + - name: 'Cache Gems' + uses: actions/cache@v1 + with: + path: vendor/bundle + key: ${{ runner.os }}-gem-${{ hashFiles('**/Gemfile.lock') }} + restore-keys: | + ${{ runner.os }}-gem- + + # Install bundler and run bundle install + - name: 'Bundle Install' + run: | + gem install bundler -v 1.17.2 + bundle config path vendor/bundle + bundle install --jobs 4 --retry 3 --without mysql rollbar aws + + # Try to retrieve the yarn JS dependencies from the cache + - name: 'Cache Yarn Packages' + uses: actions/cache@v1 + with: + path: node_modules/ + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-build-${{ env.cache-name }}- + ${{ runner.os }}-build- + ${{ runner.os }}-yarn- + ${{ runner.os }}- + + # Figure out where wkhtmltopdf is installed + - name: 'Determine wkhtmltopdf location' + run: echo ::set-env name=WICKED_PDF_PATH::$(echo `bundle exec which wkhtmltopdf`) + + # Install the JS dependencies + - name: 'Yarn Install' + run: | + yarn install + + # Setup the database + - name: 'Setup Test DB' + run: bundle exec rake db:setup RAILS_ENV=test + + # Compile the assets + - name: 'Compile Assets' + run: | + bundle exec rake webpacker:compile + bundle exec rake assets:precompile + + # Run the JS tests + - name: 'Run Karma Tests' + run: | + yarn add karma + yarn run test + + # Run the Rspec tests + - name: 'Run Rspec Tests' + run: bundle exec rspec spec/ diff --git a/.github/workflows/rubocop.yml b/.github/workflows/rubocop.yml new file mode 100644 index 0000000..1d3d2a7 --- /dev/null +++ b/.github/workflows/rubocop.yml @@ -0,0 +1,34 @@ +# Commented out until we have time to do a full cleanup of the codebase + +name: Rubocop + +on: [pull_request] + +jobs: + rubocop: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + # Extract the Ruby version from the Gemfile.lock +# - name: 'Determine Ruby Version' +# run: echo ::set-env name=RUBY_VERSION::$(echo `cat ./Gemfile.lock | grep -A 1 'RUBY VERSION' | grep 'ruby' | grep -oE '[0-9]\.[0-9]'`) + + # Install Ruby - using the version found in the Gemfile.lock +# - name: 'Install Ruby' +# uses: actions/setup-ruby@v1 +# with: +# ruby-version: ${{ env.RUBY_VERSION }} + + # Will run Rubocop checks on the PR diffs and report any errors as commentary on the PR + # https://github.com/marketplace/actions/octocop +# - name: Octocop +# uses: Freshly/Octocop@v0.0.1 +# with: +# github_token: ${{ secrets.github_token }} +# additional-gems: 'rubocop-dmp_roadmap' + + - name: 'Placeholder for Rubocop' + run: echo "Rubocop has been temporarily disabled" diff --git a/.travis.yml b/.travis.yml index d57ec59..84bcc62 100644 --- a/.travis.yml +++ b/.travis.yml @@ -36,7 +36,7 @@ rvm: # Use 2.4.1, since this is installed by default on Travis (1st Aug, 2018) - - 2.4.1 + - 2.4.4 # These env variables will set up a separate testing environment for each # combination of variables. @@ -47,8 +47,15 @@ before_install: - nvm install 10.10.0 - + + # sassc-rails causes a build failure when running assets:precompile in Travis + # for the mysql2 env. Forcing the gem to install and build ffi locally fixes + # the issue: https://github.com/sass/sassc-ruby/issues/146 + #- gem install sassc -- --disable-march-tune-native + install: + - gem install bundler -v 1.17.2 + # Install all gem and JS dependencies - bundle install --with development,ci --path=${BUNDLE_PATH:-vendor/bundle} - yarn install --ignore-optional @@ -80,10 +87,12 @@ # Run Brakeman check with warning level 2, except these two checks: - stage: security name: "Brakeman check" + if: branch = master script: bundle exec brakeman -w2 --except=Redirect,CrossSiteScripting - stage: security name: "Bundle audit" + if: branch = master script: bundle exec bundle-audit check --update --ignore CVE-2015-9284 - stage: test diff --git a/Gemfile b/Gemfile index 6f71e38..3ee9804 100644 --- a/Gemfile +++ b/Gemfile @@ -8,6 +8,9 @@ # Full-stack web application framework. (http://rubyonrails.org) gem 'rails', '~> 4.2.11.1' +# TODO: See if pegging gems is still necessary after migrating to Rails 5 +gem 'sprockets', '~> 3.2' + # Rake is a Make-like program implemented in Ruby (https://github.com/ruby/rake) gem "rake" @@ -156,6 +159,9 @@ # A feed fetching and parsing library (http://feedjira.com) gem 'feedjira' +# Http requests library (https://github.com/jnunemaker/httparty) +gem 'httparty' + # Filename sanitization for Ruby. This is useful when you generate filenames for downloads from user input gem 'zaru' @@ -191,6 +197,9 @@ # JsonPath is a way of addressing elements within a JSON object. Similar to xpath of yore, JsonPath lets you traverse a json object and manipulate or access it. gem "jsonpath" +# ------------------------------------------------- +# UTILITIES +gem 'parallel' # ------------------------------------------------ # ENVIRONMENT SPECIFIC DEPENDENCIES @@ -251,6 +260,8 @@ gem "rspec-collection_matchers" + # A set of RSpec matchers for testing Pundit authorisation policies. + gem 'pundit-matchers' end group :ci, :development do diff --git a/Gemfile.lock b/Gemfile.lock index 9e2c414..74c4de1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -43,15 +43,15 @@ tzinfo (~> 1.1) addressable (2.7.0) public_suffix (>= 2.0.2, < 5.0) - annotate (2.6.5) - activerecord (>= 2.3.0) - rake (>= 0.8.7) + annotate (3.0.3) + activerecord (>= 3.2, < 7.0) + rake (>= 10.4, < 14.0) annotate_gem (0.0.14) bundler (>= 1.1) api-pagination (4.8.2) arel (6.0.4) ast (2.4.0) - autoprefixer-rails (9.6.1.1) + autoprefixer-rails (9.7.3) execjs bcrypt (3.1.13) better_errors (2.5.1) @@ -63,8 +63,8 @@ bootstrap-sass (3.4.1) autoprefixer-rails (>= 5.2.1) sassc (>= 2.0.0) - brakeman (4.7.1) - builder (3.2.3) + brakeman (4.7.2) + builder (3.2.4) bullet (6.0.2) activesupport (>= 3.0.0) uniform_notifier (~> 1.11) @@ -82,7 +82,7 @@ rack-test (>= 0.6.3) regexp_parser (~> 1.5) xpath (~> 3.2) - capybara-screenshot (1.0.23) + capybara-screenshot (1.0.24) capybara (>= 1.0, < 4) launchy childprocess (3.0.0) @@ -123,22 +123,22 @@ erubi (1.9.0) erubis (2.7.0) eventmachine (1.2.7) - excon (0.67.0) + excon (0.71.0) execjs (2.7.0) - factory_bot (5.1.0) + factory_bot (5.1.1) activesupport (>= 4.2.0) - factory_bot_rails (5.1.0) + factory_bot_rails (5.1.1) factory_bot (~> 5.1.0) railties (>= 4.2.0) faker (2.2.1) i18n (>= 0.8) - faraday (0.16.2) + faraday (0.17.1) multipart-post (>= 1.2, < 3) fast_gettext (2.0.1) - feedjira (3.0.0) - loofah (>= 2.2.1) + feedjira (3.1.1) + loofah (>= 2.3.1) sax-machine (>= 1.0) - ffi (1.11.1) + ffi (1.11.3) flag_shih_tzu (0.3.23) fog-aws (3.5.2) fog-core (~> 2.1) @@ -159,13 +159,13 @@ font-awesome-sass (4.2.2) sass (~> 3.2) formatador (0.2.5) - fuubar (2.4.1) + fuubar (2.5.0) rspec-core (~> 3.0) ruby-progressbar (~> 1.4) gettext (3.2.9) locale (>= 2.0.5) text (>= 1.3.0) - gettext_i18n_rails (1.8.0) + gettext_i18n_rails (1.8.1) fast_gettext (>= 0.9.0) gettext_i18n_rails_js (1.3.0) gettext (>= 3.0.2) @@ -174,7 +174,7 @@ rails (>= 3.2.0) globalid (0.4.2) activesupport (>= 4.2.0) - guard (2.15.1) + guard (2.16.1) formatador (>= 0.2.4) listen (>= 2.7, < 4.0) lumberjack (>= 1.0.12, < 2.0) @@ -191,19 +191,22 @@ hana (1.3.5) hashdiff (1.0.0) hashie (3.6.0) - highline (2.0.2) + highline (2.0.3) htmltoword (1.1.0) actionpack nokogiri rubyzip (>= 1.0) + httparty (0.18.0) + mime-types (~> 3.0) + multi_xml (>= 0.5.2) i18n (0.9.5) concurrent-ruby (~> 1.0) ipaddress (0.8.3) - jaro_winkler (1.5.3) + jaro_winkler (1.5.4) jbuilder (2.6.4) activesupport (>= 3.0.0) multi_json (>= 1.2) - json (2.2.0) + json (2.3.0) json_schemer (0.2.10) ecma-re-validator (~> 0.2) hana (~> 1.3) @@ -230,7 +233,7 @@ ledermann-rails-settings (2.5.0) activerecord (>= 4.2) libv8 (7.3.492.27.1) - listen (3.2.0) + listen (3.2.1) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) locale (2.1.2) @@ -240,25 +243,23 @@ lumberjack (1.0.13) mail (2.7.1) mini_mime (>= 0.1.1) - metaclass (0.0.4) method_source (0.9.2) mime-types (3.3) mime-types-data (~> 3.2015) - mime-types-data (3.2019.0904) + mime-types-data (3.2019.1009) mini_mime (1.0.2) mini_portile2 (2.4.0) - mini_racer (0.2.6) + mini_racer (0.2.8) libv8 (>= 6.9.411) - minitest (5.12.2) - mocha (1.9.0) - metaclass (~> 0.0.1) - multi_json (1.13.1) + minitest (5.13.0) + mocha (1.11.0) + multi_json (1.14.1) multi_xml (0.6.0) multipart-post (2.1.1) mysql2 (0.4.10) nenv (0.3.0) nio4r (2.5.2) - nokogiri (1.10.5) + nokogiri (1.10.8) mini_portile2 (~> 2.4.0) notiffany (0.1.3) nenv (~> 0.1) @@ -285,25 +286,27 @@ omniauth (>= 1.0.0) options (2.3.2) orm_adapter (0.5.0) - parallel (1.17.0) - parser (2.6.4.1) + parallel (1.19.1) + parser (2.6.5.0) ast (~> 2.4.0) pg (0.19.0) po_to_json (1.0.1) json (>= 1.6.0) - progress_bar (1.3.0) + progress_bar (1.3.1) highline (>= 1.6, < 3) options (~> 2.3.0) pry (0.12.2) coderay (~> 1.1.0) method_source (~> 0.9.0) public_suffix (4.0.1) - puma (4.2.0) + puma (4.3.3) nio4r (~> 2.0) pundit (2.1.0) activesupport (>= 3.0.0) - rack (1.6.11) - rack-mini-profiler (1.1.0) + pundit-matchers (1.6.0) + rspec-rails (>= 3.0.0) + rack (1.6.13) + rack-mini-profiler (1.1.4) rack (>= 1.2.0) rack-proxy (0.6.5) rack @@ -339,62 +342,62 @@ rake (>= 0.8.7) thor (>= 0.18.1, < 2.0) rainbow (3.0.0) - rake (13.0.0) + rake (13.0.1) rb-fsevent (0.10.3) rb-inotify (0.10.0) ffi (~> 1.0) - recaptcha (5.1.1) + recaptcha (5.2.1) json regexp_parser (1.6.0) responders (2.4.1) actionpack (>= 4.2.0, < 6.0) railties (>= 4.2.0, < 6.0) rollbar (2.22.1) - rspec (3.8.0) - rspec-core (~> 3.8.0) - rspec-expectations (~> 3.8.0) - rspec-mocks (~> 3.8.0) + rspec (3.9.0) + rspec-core (~> 3.9.0) + rspec-expectations (~> 3.9.0) + rspec-mocks (~> 3.9.0) rspec-collection_matchers (1.2.0) rspec-expectations (>= 2.99.0.beta1) - rspec-core (3.8.2) - rspec-support (~> 3.8.0) - rspec-expectations (3.8.5) + rspec-core (3.9.0) + rspec-support (~> 3.9.0) + rspec-expectations (3.9.0) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.8.0) - rspec-mocks (3.8.2) + rspec-support (~> 3.9.0) + rspec-mocks (3.9.0) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.8.0) - rspec-rails (3.8.2) + rspec-support (~> 3.9.0) + rspec-rails (3.9.0) actionpack (>= 3.0) activesupport (>= 3.0) railties (>= 3.0) - rspec-core (~> 3.8.0) - rspec-expectations (~> 3.8.0) - rspec-mocks (~> 3.8.0) - rspec-support (~> 3.8.0) - rspec-support (3.8.3) - rubocop (0.75.0) + rspec-core (~> 3.9.0) + rspec-expectations (~> 3.9.0) + rspec-mocks (~> 3.9.0) + rspec-support (~> 3.9.0) + rspec-support (3.9.0) + rubocop (0.77.0) jaro_winkler (~> 1.5.1) parallel (~> 1.10) parser (>= 2.6) rainbow (>= 2.2.2, < 4.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 1.4.0, < 1.7) - rubocop-dmp_roadmap (1.1.0) + rubocop-dmp_roadmap (1.1.2) rubocop (>= 0.58.2) rubocop-rails_config (>= 0.2.2) rubocop-rspec (>= 1.27.0) - rubocop-performance (1.5.0) + rubocop-performance (1.5.1) rubocop (>= 0.71.0) - rubocop-rails (2.3.2) + rubocop-rails (2.4.0) rack (>= 1.1) rubocop (>= 0.72.0) - rubocop-rails_config (0.7.2) + rubocop-rails_config (0.9.0) railties (>= 3.0) - rubocop (~> 0.74) + rubocop (~> 0.77) rubocop-performance (~> 1.3) rubocop-rails (~> 2.0) - rubocop-rspec (1.36.0) + rubocop-rspec (1.37.1) rubocop (>= 0.68.1) ruby-progressbar (1.10.1) ruby_dig (0.0.2) @@ -454,7 +457,7 @@ tzinfo (1.2.5) thread_safe (~> 0.1) unicode-display_width (1.6.0) - uniform_notifier (1.12.1) + uniform_notifier (1.13.0) uri_template (0.7.0) warden (1.2.7) rack (>= 1.0) @@ -475,7 +478,7 @@ rack-proxy (>= 0.6.1) railties (>= 4.2) wicked_pdf (1.1.0) - wkhtmltopdf-binary (0.12.4) + wkhtmltopdf-binary (0.12.5) xpath (3.2.0) nokogiri (~> 1.8) yard (0.9.20) @@ -521,6 +524,7 @@ guard guard-rspec htmltoword (= 1.1.0) + httparty jbuilder (~> 2.6.0) json_schemer jsonpath @@ -533,10 +537,12 @@ omniauth-orcid omniauth-rails_csrf_protection omniauth-shibboleth + parallel pg (~> 0.19.0) progress_bar puma pundit + pundit-matchers rack-mini-profiler rails (~> 4.2.11.1) rails_12factor @@ -555,6 +561,7 @@ simplecov spring spring-commands-rspec + sprockets (~> 3.2) text thin web-console @@ -568,7 +575,7 @@ zaru RUBY VERSION - ruby 2.5.1p57 + ruby 2.4.0p0 BUNDLED WITH 1.17.3 diff --git a/app/assets/stylesheets/blocks/_new_window_popup.scss b/app/assets/stylesheets/blocks/_new_window_popup.scss index a1b764b..a499e26 100644 --- a/app/assets/stylesheets/blocks/_new_window_popup.scss +++ b/app/assets/stylesheets/blocks/_new_window_popup.scss @@ -1,9 +1,9 @@ a.has-new-window-popup-info, button.has-new-window-popup-info { - + position:relative; z-index:24; - + & > span.new-window-popup-info { position: absolute; left: -9000px; @@ -14,9 +14,9 @@ &:hover, &:focus, &:active { - + z-index:25; - + & > span.new-window-popup-info { display:block; position:absolute; @@ -29,7 +29,7 @@ color:#000; text-align: center; } - + } - + } diff --git a/app/assets/stylesheets/blocks/_readonly_textarea.scss b/app/assets/stylesheets/blocks/_readonly_textarea.scss index ec288cf..4ee99c6 100644 --- a/app/assets/stylesheets/blocks/_readonly_textarea.scss +++ b/app/assets/stylesheets/blocks/_readonly_textarea.scss @@ -1,17 +1,17 @@ /* For display of readonly textarea content without the TinyMCE editor */ .display-readonly-textarea-content { // Replicating some TinyMCE styling of textarea - overflow-y: hidden; + overflow: visible; padding-left: 1px; padding-right: 1px; padding-bottom: 10px; - + // Ensure table borders are not lost table { td { border: 1px solid black; } - + td, tr { padding: 10px; } diff --git a/app/assets/stylesheets/blocks/_usage.sccs b/app/assets/stylesheets/blocks/_usage.sccs new file mode 100644 index 0000000..1eac12f --- /dev/null +++ b/app/assets/stylesheets/blocks/_usage.sccs @@ -0,0 +1,4 @@ +.single-char-input { + display: inline; + width: 20px; +} diff --git a/app/assets/stylesheets/dmpopidor.scss b/app/assets/stylesheets/dmpopidor.scss index ec870a2..3bd9a73 100644 --- a/app/assets/stylesheets/dmpopidor.scss +++ b/app/assets/stylesheets/dmpopidor.scss @@ -3,7 +3,7 @@ */ $blue: #2c7dad; -$light-blue: #92c5de; +$dark-blue: #1c5170; $very-light-blue: #dcecf6; $very-very-light-blue: #edf5fa; $white: #fff; @@ -19,6 +19,13 @@ padding-bottom: 0; } +.modal-backdrop { + z-index: 4; +} + +#header-signin { + z-index: 7; +} /* MENU & FOOTER @@ -226,7 +233,7 @@ /* DROPDOWNS */ -#change-language, #signin-signout, #app-navbar-menu { +#change-language, #signin-signout, #app-navbar-menu, #admin-dropdown, .plan-actions { .dmpopidor-dropdown { background-color: $white; @@ -281,6 +288,19 @@ background-color: $blue; } + .org-deactivated { + max-width: 1264px; + color: $rust; + text-align: center; + margin: auto; + padding-top: 5px; + font-weight: 600; + + a { + color: black; + } + } + /** Focus outline required for accessibility */ input, select, .form-control { &:focus, @@ -300,7 +320,7 @@ outline-width: 1px !important; } - &:hover { + &:hover:not(.accessible) { outline-style: none !important; } } @@ -312,12 +332,12 @@ color: $blue; } - h2 { + h2, .fontsize-h2 { color: $rust; font-size: 20px; } - h3 { + h3, .fontsize-h3 { color: $blue; font-size: 17px; } @@ -423,6 +443,21 @@ border-bottom-color: transparent; background-color: $white; } + + li.phase-tab > a { + background-color: $dark-blue; + } + + li.phase-tab > a:hover, + li.phase-tab > a:focus, + li.active.phase-tab > a, + li.active.phase-tab > a:focus, + li.active.phase-tab > a:hover { + color: $dark-blue; + background-color: white; + border: 1px solid $dark-blue; + border-bottom-color: transparent; + } } .nav-pills { background-color: $blue; @@ -541,8 +576,6 @@ border-bottom: 2px solid $very-very-light-blue; } - - /* ADMIN AREA */ @@ -781,6 +814,20 @@ } +/* + COOKIE BANNER +*/ + +.cookiebanner-close { + &:focus, + &:hover, + &:active { + outline-style: solid !important; + outline-color: $blue !important; + outline-width: 2px !important; + } +} + /* FRONT PAGE */ diff --git a/app/assets/stylesheets/utils/_colours.scss b/app/assets/stylesheets/utils/_colours.scss index 5616249..eb7e455 100644 --- a/app/assets/stylesheets/utils/_colours.scss +++ b/app/assets/stylesheets/utils/_colours.scss @@ -1,3 +1,8 @@ .red { color: $color-text-red; } + +//default colour used on headings +.color-heading-text{ + color: $color-heading-text; +} diff --git a/app/assets/stylesheets/utils/_font_size.scss b/app/assets/stylesheets/utils/_font_size.scss new file mode 100644 index 0000000..4ef54ab --- /dev/null +++ b/app/assets/stylesheets/utils/_font_size.scss @@ -0,0 +1,18 @@ + +//similar font size as an h2 tag +.fontsize-h2{ + font-size: 24px; + +} + +//similar font size as an h3 tag +.fontsize-h3{ + font-size: 18.72px; + +} + +//similar font size as an h4 tag +.fontsize-h4{ + font-size: 16px; + +} diff --git a/app/assets/stylesheets/utils/_margins.scss b/app/assets/stylesheets/utils/_margins.scss index 7b0f764..1461cc8 100644 --- a/app/assets/stylesheets/utils/_margins.scss +++ b/app/assets/stylesheets/utils/_margins.scss @@ -4,6 +4,12 @@ .mb-10 { margin-bottom: 10px; } +.mt-5 { + margin-top: 5px; +} .mt-10 { margin-top: 10px; } +.mt-20 { + margin-top: 20px; +} diff --git a/app/controllers/answers_controller.rb b/app/controllers/answers_controller.rb index 12394a8..07d5c90 100644 --- a/app/controllers/answers_controller.rb +++ b/app/controllers/answers_controller.rb @@ -15,29 +15,29 @@ begin p = Plan.find(p_params[:plan_id]) if !p.question_exists?(p_params[:question_id]) - # rubocop:disable LineLength + # rubocop:disable Metrics/LineLength render(status: :not_found, json: { msg: _("There is no question with id %{question_id} associated to plan id %{plan_id} for which to create or update an answer") % { question_id: p_params[:question_id], plan_id: p_params[:plan_id] } }) - # rubocop:enable LineLength + # rubocop:enable Metrics/LineLength return end rescue ActiveRecord::RecordNotFound - # rubocop:disable LineLength + # rubocop:disable Metrics/LineLength render(status: :not_found, json: { msg: _("There is no plan with id %{id} for which to create or update an answer") % { id: p_params[:plan_id] } }) - # rubocop:enable LineLength + # rubocop:enable Metrics/LineLength return end q = Question.find(p_params[:question_id]) - # rubocop:disable BlockLength + # rubocop:disable Metrics/BlockLength Answer.transaction do begin @answer = Answer.find_by!( @@ -75,7 +75,7 @@ ) end end - # rubocop:enable BlockLength + # rubocop:enable Metrics/BlockLength if @answer.present? @plan = Plan.includes( @@ -90,7 +90,7 @@ @section = @plan.sections.find_by(id: @question.section_id) template = @section.phase.template - # rubocop:disable LineLength + # rubocop:disable Metrics/LineLength render json: { "question" => { "id" => @question.id, @@ -129,7 +129,7 @@ }, formats: [:html]) } }.to_json - # rubocop:enable LineLength + # rubocop:enable Metrics/LineLength end end diff --git a/app/controllers/api/v0/plans_controller.rb b/app/controllers/api/v0/plans_controller.rb index 783002d..8f8dae2 100644 --- a/app/controllers/api/v0/plans_controller.rb +++ b/app/controllers/api/v0/plans_controller.rb @@ -75,6 +75,9 @@ if params["updated_after"].present? || params["updated_before"].present? @plans = @plans.where(updated_at: dates_to_range(params,"updated_after","updated_before")) end + if params["remove_tests"].present? && params["remove_tests"].downcase == "true" + @plans = @plans.where.not(visibility: Plan.visibilities[:is_test]) + end # filter on funder (dmptemplate_id) template_ids = extract_param_list(params, "template") @plans = @plans.where(templates: {family_id: template_ids}) if template_ids.present? diff --git a/app/controllers/api/v0/statistics_controller.rb b/app/controllers/api/v0/statistics_controller.rb index c06ef51..244e0bf 100644 --- a/app/controllers/api/v0/statistics_controller.rb +++ b/app/controllers/api/v0/statistics_controller.rb @@ -194,6 +194,9 @@ raise Pundit::NotAuthorizedError end @org_plans = @user.org.plans + if params["remove_tests"].present? && params["remove_tests"].downcase == "true" + @org_plans = @org_plans.where.not(visibility: Plan.visibilities[:is_test]) + end if params["start_date"].present? || params["end_date"].present? @org_plans = @org_plans.where(created_at: dates_to_range(params)) end diff --git a/app/controllers/feedback_requests_controller.rb b/app/controllers/feedback_requests_controller.rb index af26af1..d85b548 100644 --- a/app/controllers/feedback_requests_controller.rb +++ b/app/controllers/feedback_requests_controller.rb @@ -2,6 +2,7 @@ class FeedbackRequestsController < ApplicationController + prepend Dmpopidor::Controllers::FeedbackRequests include FeedbacksHelper after_action :verify_authorized @@ -9,6 +10,7 @@ ALERT = _("Unable to submit your request for feedback at this time.") ERROR = _("An error occurred when requesting feedback for this plan.") + # SEE MODULE def create @plan = Plan.find(params[:plan_id]) authorize @plan, :request_feedback? diff --git a/app/controllers/guidance_groups_controller.rb b/app/controllers/guidance_groups_controller.rb index 6e6cd19..805f112 100644 --- a/app/controllers/guidance_groups_controller.rb +++ b/app/controllers/guidance_groups_controller.rb @@ -70,9 +70,9 @@ @guidance_group.published = true if @guidance_group.save - # rubocop:disable LineLength + # rubocop:disable Metrics/LineLength flash[:notice] = _("Your guidance group has been published and is now available to users.") - # rubocop:enable LineLength + # rubocop:enable Metrics/LineLength else flash[:alert] = failure_message(@guidance_group, _("publish")) end @@ -87,9 +87,9 @@ @guidance_group.published = false if @guidance_group.save - # rubocop:disable LineLength + # rubocop:disable Metrics/LineLength flash[:notice] = _("Your guidance group is no longer published and will not be available to users.") - # rubocop:enable LineLength + # rubocop:enable Metrics/LineLength else flash[:alert] = failure_message(@guidance_group, _("unpublish")) end diff --git a/app/controllers/guidances_controller.rb b/app/controllers/guidances_controller.rb index 28fa7c9..1abd9f7 100644 --- a/app/controllers/guidances_controller.rb +++ b/app/controllers/guidances_controller.rb @@ -103,9 +103,9 @@ guidance_group.published = true guidance_group.save end - # rubocop:disable LineLength + # rubocop:disable Metrics/LineLength flash[:notice] = _("Your guidance has been published and is now available to users.") - # rubocop:enable LineLength + # rubocop:enable Metrics/LineLength else flash[:alert] = failure_message(@guidance, _("publish")) end @@ -122,9 +122,9 @@ guidance_group.published = false guidance_group.save end - # rubocop:disable LineLength + # rubocop:disable Metrics/LineLength flash[:notice] = _("Your guidance is no longer published and will not be available to users.") - # rubocop:enable LineLength + # rubocop:enable Metrics/LineLength else flash[:alert] = failure_message(@guidance, _("unpublish")) end diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb index e771179..afb2b83 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -23,6 +23,9 @@ else redirect_to plans_url end + elsif session["devise.shibboleth_data"].present? + # NOTE: Update this to handle ORCiD as well when we enable it as a login method + redirect_to new_user_registration_url end end diff --git a/app/controllers/org_admin/plans_controller.rb b/app/controllers/org_admin/plans_controller.rb index 9cfcda5..4202586 100644 --- a/app/controllers/org_admin/plans_controller.rb +++ b/app/controllers/org_admin/plans_controller.rb @@ -23,6 +23,7 @@ # GET org_admin/plans/:id/feedback_complete def feedback_complete plan = Plan.find(params[:id]) + requestor = User.find(plan.feedback_requestor) # Test auth directly and throw Pundit error sincePundit is # unaware of namespacing unless current_user.present? && current_user.can_org_admin? @@ -33,13 +34,13 @@ end if plan.complete_feedback(current_user) - # rubocop:disable LineLength + # rubocop:disable Metrics/LineLength redirect_to(org_admin_plans_path, notice: _("%{plan_owner} has been notified that you have finished providing feedback") % { - plan_owner: plan.owner.name(false) + plan_owner: requestor.name(false) } ) - # rubocop:enable LineLength + # rubocop:enable Metrics/LineLength else redirect_to org_admin_plans_path, alert: _("Unable to notify user that you have finished providing feedback.") diff --git a/app/controllers/org_admin/templates_controller.rb b/app/controllers/org_admin/templates_controller.rb index fc9fd96..9a82c50 100644 --- a/app/controllers/org_admin/templates_controller.rb +++ b/app/controllers/org_admin/templates_controller.rb @@ -150,8 +150,12 @@ # POST /org_admin/templates def create authorize Template + args = template_params + # Swap in the appropriate visibility enum value for the checkbox value + args[:visibility] = args.fetch(:visibility, "0") == "1" ? "organisationally_visible" : "publicly_visible" + # creates a new template with version 0 and new family_id - @template = Template.new(template_params) + @template = Template.new(args) @template.org_id = current_user.org.id @template.locale = current_org.language.abbreviation @template.links = if params["template-links"].present? @@ -316,7 +320,7 @@ end format.pdf do - # rubocop:disable LineLength + # rubocop:disable Metrics/LineLength render pdf: file_name, template: "template_exports/template_export", margin: @formatting[:margin], @@ -330,7 +334,7 @@ right: "[page] of [topage]", encoding: "utf8" } - # rubocop:enable LineLength + # rubocop:enable Metrics/LineLength end end rescue ActiveRecord::RecordInvalid => e diff --git a/app/controllers/org_admin/users_controller.rb b/app/controllers/org_admin/users_controller.rb index e2c730c..ae7866b 100644 --- a/app/controllers/org_admin/users_controller.rb +++ b/app/controllers/org_admin/users_controller.rb @@ -4,13 +4,16 @@ class UsersController < ApplicationController + prepend Dmpopidor::Controllers::OrgAdmin::Users + after_action :verify_authorized + # SEE MODULE def edit @user = User.find(params[:id]) authorize @user @departments = @user.org.departments.order(:name) - @plans = Plan.active(@user).page(1) + @plans = Plan.org_admin_visible(@user).page(1) render "org_admin/users/edit", locals: { user: @user, departments: @departments, @@ -21,6 +24,7 @@ default_org: @user.org } end + # SEE MODULE def update @user = User.find(params[:id]) authorize @user @@ -37,6 +41,7 @@ render :edit end + # SEE MODULE def user_plans @user = User.find(params[:id]) authorize @user diff --git a/app/controllers/orgs_controller.rb b/app/controllers/orgs_controller.rb index fe9fd43..7bc9f94 100644 --- a/app/controllers/orgs_controller.rb +++ b/app/controllers/orgs_controller.rb @@ -110,10 +110,11 @@ end private + # SEE MODULE def org_params params.require(:org).permit(:name, :abbreviation, :logo, :contact_email, :contact_name, :remove_logo, :org_type, - :feedback_enabled, :feedback_email_msg, :banner_text) + :feedback_enabled, :feedback_email_msg) end end diff --git a/app/controllers/paginable/plans_controller.rb b/app/controllers/paginable/plans_controller.rb index 5019101..3501903 100644 --- a/app/controllers/paginable/plans_controller.rb +++ b/app/controllers/paginable/plans_controller.rb @@ -59,7 +59,7 @@ end paginable_renderise( partial: "org_admin_other_user", - scope: Plan.organisationally_or_publicly_visible(@user), + scope: Plan.active(@user), query_params: { sort_field: 'plans.updated_at', sort_direction: :desc } ) end diff --git a/app/controllers/paginable/templates_controller.rb b/app/controllers/paginable/templates_controller.rb index 71373e9..5ec6dd8 100644 --- a/app/controllers/paginable/templates_controller.rb +++ b/app/controllers/paginable/templates_controller.rb @@ -7,7 +7,7 @@ include Paginable # TODO: Clean up this code for Rubocop - # rubocop:disable LineLength + # rubocop:disable Metrics/LineLength # GET /paginable/templates/:page (AJAX) # ----------------------------------------------------- @@ -86,7 +86,7 @@ ) end - # rubocop:enable LineLength + # rubocop:enable Metrics/LineLength # GET /paginable/templates/publicly_visible/:page (AJAX) # ----------------------------------------------------- diff --git a/app/controllers/plans_controller.rb b/app/controllers/plans_controller.rb index d54a9ba..39315bc 100644 --- a/app/controllers/plans_controller.rb +++ b/app/controllers/plans_controller.rb @@ -25,6 +25,7 @@ end # GET /plans/new + # SEE MODULE def new @plan = Plan.new authorize @plan diff --git a/app/controllers/public_pages_controller.rb b/app/controllers/public_pages_controller.rb index aa9e693..e8cf23d 100644 --- a/app/controllers/public_pages_controller.rb +++ b/app/controllers/public_pages_controller.rb @@ -52,7 +52,7 @@ end format.pdf do - # rubocop:disable LineLength + # rubocop:disable Metrics/LineLength render pdf: file_name, template: "template_exports/template_export", margin: @formatting[:margin], @@ -66,7 +66,7 @@ right: "[page] of [topage]", encoding: "utf8" } - # rubocop:enable LineLength + # rubocop:enable Metrics/LineLength end end rescue ActiveRecord::RecordInvalid => e diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index f835b42..0f5a894 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -4,7 +4,6 @@ prepend Dmpopidor::Controllers::Registrations - # SEE MODULE def edit @user = current_user @prefs = @user.get_preferences(:email) @@ -34,11 +33,11 @@ # The OAuth provider could not be determined or there was no unique UID! if !oauth["provider"].nil? && !oauth["uid"].nil? # Connect the new user with the identifier sent back by the OAuth provider - # rubocop:disable LineLength + # rubocop:disable Metrics/LineLength flash[:notice] = _("Please make a choice below. After linking your details to a %{application_name} account, you will be able to sign in directly with your institutional credentials.") % { application_name: Rails.configuration.branding[:application][:name] } - # rubocop:enable LineLength + # rubocop:enable Metrics/LineLength scheme = IdentifierScheme.find_by(name: oauth["provider"].downcase) UserIdentifier.create(identifier_scheme: scheme, identifier: oauth["uid"], @@ -56,14 +55,14 @@ end end - if params[:accept_terms].to_s == "0" + if params[:user][:accept_terms].to_s == "0" redirect_to after_sign_up_error_path_for(resource), alert: _("You must accept the terms and conditions to register.") elsif params[:user][:org_id].blank? && params[:user][:other_organisation].blank? - # rubocop:disable LineLength + # rubocop:disable Metrics/LineLength redirect_to after_sign_up_error_path_for(resource), alert: _("Please select an organisation from the list, or enter your organisation's name.") - # rubocop:enable LineLength + # rubocop:enable Metrics/LineLength else existing_user = User.where_case_insensitive("email", sign_up_params[:email]).first if existing_user.present? @@ -84,10 +83,10 @@ if params[:user][:org_id].blank? other_org = Org.find_by(is_other: true) if other_org.nil? - # rubocop:disable LineLength + # rubocop:disable Metrics/LineLength redirect_to(after_sign_up_error_path_for(resource), alert: _("You cannot be assigned to other organisation since that option does not exist in the system. Please contact your system administrators.")) and return - # rubocop:enable LineLength + # rubocop:enable Metrics/LineLength end params[:user][:org_id] = other_org.id end @@ -107,9 +106,9 @@ UserIdentifier.create(identifier_scheme: prov, identifier: oauth["uid"], user: @user) - # rubocop:disable LineLength + # rubocop:disable Metrics/LineLength flash[:notice] = _("Welcome! You have signed up successfully with your institutional credentials. You will now be able to access your account with them.") - # rubocop:enable LineLength + # rubocop:enable Metrics/LineLength end end end @@ -122,10 +121,10 @@ end else clean_up_passwords resource - # rubocop:disable LineLength + # rubocop:disable Metrics/LineLength redirect_to after_sign_up_error_path_for(resource), alert: _("Unable to create your account.#{errors_for_display(resource)}") - # rubocop:enable LineLength + # rubocop:enable Metrics/LineLength end end end @@ -175,9 +174,9 @@ mandatory_params &&= false end if params[:user][:org_id].blank? && params[:user][:other_organisation].blank? - # rubocop:disable LineLength + # rubocop:disable Metrics/LineLength message += _("Please select an organisation from the list, or enter your organisation's name.") - # rubocop:enable LineLength + # rubocop:enable Metrics/LineLength mandatory_params &&= false end # has the user entered all the details @@ -229,7 +228,7 @@ set_gettext_locale set_flash_message :notice, success_message(current_user, _("saved")) # Sign in the user bypassing validation in case his password changed - sign_in current_user, bypass: true + bypass_sign_in current_user redirect_to "#{edit_user_registration_path}\#personal-details", notice: success_message(current_user, _("saved")) @@ -255,7 +254,7 @@ # Method defined at controllers/application_controller.rbset_gettext_locale set_flash_message :notice, success_message(current_user, _("saved")) # TODO this method is deprecated - sign_in current_user, bypass: true + bypass_sign_in current_user redirect_to "#{edit_user_registration_path}\#password-details", notice: success_message(current_user, _("saved")) diff --git a/app/controllers/roles_controller.rb b/app/controllers/roles_controller.rb index 46dd1ee..b8c3d0f 100644 --- a/app/controllers/roles_controller.rb +++ b/app/controllers/roles_controller.rb @@ -3,6 +3,7 @@ class RolesController < ApplicationController include ConditionalUserMailer + prepend Dmpopidor::Controllers::Roles respond_to :html after_action :verify_authorized @@ -16,11 +17,11 @@ message = "" if params[:user].present? && plan.present? if @role.plan.owner.present? && @role.plan.owner.email == params[:user] - # rubocop:disable LineLength + # rubocop:disable Metrics/LineLength flash[:notice] = _("Cannot share plan with %{email} since that email matches with the owner of the plan.") % { email: params[:user] } - # rubocop:enable LineLength + # rubocop:enable Metrics/LineLength else user = User.where_case_insensitive("email", params[:user]).first if Role.find_by(plan: @role.plan, user: user) # role already exists @@ -53,9 +54,9 @@ end flash[:notice] = message else - # rubocop:disable LineLength + # rubocop:disable Metrics/LineLength flash[:alert] = _("You must provide a valid email address and select a permission level.") - # rubocop:enable LineLength + # rubocop:enable Metrics/LineLength end end end @@ -74,12 +75,12 @@ deliver_if(recipients: @role.user, key: "users.added_as_coowner") do |r| UserMailer.permissions_change_notification(@role, current_user).deliver_now end - # rubocop:disable LineLength + # rubocop:disable Metrics/LineLength render json: { code: 1, msg: _("Successfully changed the permissions for %{email}. They have been notified via email.") % { email: @role.user.email } } - # rubocop:enable LineLength + # rubocop:enable Metrics/LineLength else render json: { code: 0, msg: flash[:alert] } end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index a8297d2..e7cc939 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -20,9 +20,9 @@ user: existing_user } if UserIdentifier.create(args) - # rubocop:disable LineLength + # rubocop:disable Metrics/LineLength success = _("Your account has been successfully linked to your institutional credentials. You will now be able to sign in with them.") - # rubocop:enable LineLength + # rubocop:enable Metrics/LineLength end end unless existing_user.get_locale.nil? diff --git a/app/controllers/settings/plans_controller.rb b/app/controllers/settings/plans_controller.rb index b1c87ba..7d80bbd 100644 --- a/app/controllers/settings/plans_controller.rb +++ b/app/controllers/settings/plans_controller.rb @@ -35,9 +35,9 @@ if settings.save flash[:notice] = _("Export settings updated successfully.") else - # rubocop:disable LineLength + # rubocop:disable Metrics/LineLength flash[:alert] = _("An error has occurred while saving/resetting your export settings.") - # rubocop:enable LineLength + # rubocop:enable Metrics/LineLength end respond_to do |format| @phase_options = @plan.phases.order(:number).pluck(:title, :id) diff --git a/app/controllers/stat_created_plans_by_template_controller.rb b/app/controllers/stat_created_plans_by_template_controller.rb deleted file mode 100644 index 937f9ed..0000000 --- a/app/controllers/stat_created_plans_by_template_controller.rb +++ /dev/null @@ -1,48 +0,0 @@ -# frozen_string_literal: true - -class StatCreatedPlansByTemplateController < ApplicationController - - def index - check_authorized! - - data = StatCreatedPlan.monthly_range(index_filter).order(date: :desc) - template_filter = params[:templates] - - if params[:format] == "csv" - p params - case template_filter - when 'any' - data_csvified = StatCreatedPlan.to_csv(data, details: { any_template: true }) - when 'org' - data_csvified = StatCreatedPlan.to_csv(data, details: { org_template: true }) - end - - send_data(data_csvified, filename: "created_plan_any_template.csv") - else - case template_filter - when 'any' - render(json: data.as_json(only: [:date, :count], methods: :any_template)) - when 'org' - render(json: data.as_json(only: [:date, :count], methods: :org_template)) - end - end - end - - private - - def index_filter - { - org: current_user.org, - start_date: params[:start_date], - end_date: params[:end_date] - } - end - - def check_authorized! - unless current_user.present? && - current_user.can_org_admin? - raise Pundit::NotAuthorizedError - end - end - -end diff --git a/app/controllers/static_pages_controller.rb b/app/controllers/static_pages_controller.rb index f6cc53f..50af195 100644 --- a/app/controllers/static_pages_controller.rb +++ b/app/controllers/static_pages_controller.rb @@ -4,19 +4,10 @@ prepend Dmpopidor::Controllers::StaticPages - # def about_us - # dcc_news_feed_url = "http://www.dcc.ac.uk/news/dmponline-0/feed" - # @dcc_news_feed = Feedjira::Feed.fetch_and_parse dcc_news_feed_url - # respond_to do |format| - # format.rss { redirect_to dcc_news_feed_url } - # format.html - # end - # end - - def contact_us + def about_us end - def roadmap + def contact_us end def privacy diff --git a/app/controllers/usage_controller.rb b/app/controllers/usage_controller.rb index 3ba84b4..9a65f63 100644 --- a/app/controllers/usage_controller.rb +++ b/app/controllers/usage_controller.rb @@ -2,24 +2,211 @@ class UsageController < ApplicationController - def index - check_authorized! + after_action :verify_authorized - render("index", - locals: { - orgs: Org.all, - total_org_users: current_user.org.users.size, - total_org_plans: current_user.org.plans.size - } - ) + # GET /usage + def index + authorize :usage + + args = default_query_args + user_data(args: args, as_json: true) + plan_data(args: args, as_json: true) + total_plans(args: min_max_dates(args: args)) + total_users(args: min_max_dates(args: args)) + #TODO: pull this in from branding.yml + @separators = [",", "|", "#"] + @funder = current_user.org.funder? + end + + # POST /usage_plans_by_template + def plans_by_template + # This action is triggered when a user changes the timeframe for the + # plans by template chart + authorize :usage + + args = default_query_args + if usage_params["template_plans_range"].present? + args[:start_date] = usage_params["template_plans_range"] + end + plan_data(args: args, as_json: true) + end + + # GET + def global_statistics + # This action is triggered when a user clicks on the 'download csv' button + # for global usage + authorize :usage + + data = Org::TotalCountStatService.call + sep = sep_param + data_csvified = Csvable.from_array_of_hashes(data, true, sep) + + send_data(data_csvified, filename: "totals.csv") + end + + # GET + def org_statistics + authorize :usage + + data = Org::MonthlyUsageService.call(current_user) + sep = sep_param + data_csvified = Csvable.from_array_of_hashes(data, true, sep) + + send_data(data_csvified, filename: "totals.csv") + end + + # POST /usage_filter + # rubocop:disable Metrics/MethodLength + def filter + # This action is triggered when a user specifies a date range + authorize :usage + + args = args_from_params + plan_data(args: args) + user_data(args: args) + total_plans(args: min_max_dates(args: args)) + total_users(args: min_max_dates(args: args)) + + @topic = usage_params[:topic] + case @topic + when "plans" + @total = @total_org_plans + @ranged = @plans_per_month.sum(:count) + else + @total = @total_org_users + @ranged = @users_per_month.sum(:count) + end + end + # rubocop:enable Metrics/MethodLength + + # GET /usage_yearly_users + def yearly_users + # This action is triggered when a user clicks on the 'download csv' button + # for the annual users chart + authorize :usage + + user_data(args: default_query_args) + sep = sep_param + send_data(CSV.generate({:col_sep => sep}) do |csv| + csv << [_("Month"), _("No. Users joined")] + total = 0 + @users_per_month.each do |data| + csv << [data.date.strftime("%b-%y"), data.count] + total += data.count + end + csv << [_("Total"), total] + end, filename: "users_joined.csv") + end + + # GET /usage_yearly_plans + def yearly_plans + # This action is triggered when a user clicks on the 'download csv' button + # for the annual plans chart + authorize :usage + + plan_data(args: default_query_args) + sep = sep_param + send_data(CSV.generate({:col_sep => sep}) do |csv| + csv << [_("Month"), _("No. Completed Plans")] + total = 0 + @plans_per_month.each do |data| + csv << [data.date.strftime("%b-%y"), data.count] + total += data.count + end + csv << [_("Total"), total] + end, filename: "completed_plans.csv") + end + + # GET /usage_all_plans_by_template + def all_plans_by_template + # This action is triggered when a user clicks on the 'download csv' button + # for the plans by template chart + authorize :usage + + args = default_query_args + args[:start_date] = first_plan_date + sep = sep_param + {:col_sep => sep} + + plan_data(args: args, sort: :desc) + data_csvified = StatCreatedPlan.to_csv(@plans_per_month, details: { by_template: true, sep: sep }) + send_data(data_csvified, filename: "created_plan_by_template.csv") end private - def check_authorized! - unless current_user.present? && - (current_user.can_org_admin? || current_user.can_super_admin?) - raise Pundit::NotAuthorizedError - end + def usage_params + params.require(:usage).permit(:template_plans_range, :org_id, :start_date, + :end_date, :topic) end + + # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity + # rubocop:disable Metrics/MethodLength + def args_from_params + org = current_user.org + if current_user.can_super_admin? && usage_params[:org_id].present? + org = Org.find_by(id: usage_params[:org_id]) + end + + start_date = usage_params[:start_date] if usage_params[:start_date].present? + end_date = usage_params[:end_date] if usage_params[:end_date].present? + + { + org: org, + start_date: start_date.present? ? start_date : first_plan_date.strftime("%Y-%m-%d"), + end_date: end_date.present? ? end_date : Date.today.strftime("%Y-%m-%d") + } + end + # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity + # rubocop:enable Metrics/MethodLength + + def default_query_args + # Stats are generated at the beginning of each month, so our reference + # point would be the end of the prior month. For example if it is December + # 15th 2019 then the most recent stats would be for the month of November 2019. + # That means we want our date range to be 11/30/2018 to 11/30/2019 + { + org: current_user.org, + start_date: Date.today.months_ago(12).end_of_month.strftime("%Y-%m-%d"), + end_date: Date.today.last_month.end_of_month.strftime("%Y-%m-%d") + } + end + + # set the csv separator or default to comma + def sep_param + params["sep"] || ',' + end + + def min_max_dates(args:) + args[:start_date] = first_plan_date.strftime("%Y-%m-%d") + args[:end_date] = Date.today.strftime("%Y-%m-%d") + args + end + + def user_data(args:, as_json: false, sort: :asc) + @users_per_month = StatJoinedUser.monthly_range(args) + .order(date: sort) + @users_per_month = @users_per_month.map { |rec| rec.to_json } if as_json + end + + def plan_data(args:, as_json: false, sort: :asc) + @plans_per_month = StatCreatedPlan.monthly_range(args) + .where.not(details: "{\"by_template\":[]}") + .order(date: sort) + @plans_per_month = @plans_per_month.map { |rec| rec.to_json } if as_json + end + + def total_plans(args:) + @total_org_plans = StatCreatedPlan.monthly_range(args).sum(:count) + end + + def total_users(args:) + @total_org_users = StatJoinedUser.monthly_range(args).sum(:count) + end + + def first_plan_date + StatCreatedPlan.all.order(:date).limit(1).pluck(:date).first \ + || Date.today.last_month.end_of_month + end + end diff --git a/app/controllers/users/invitations_controller.rb b/app/controllers/users/invitations_controller.rb index b3e841b..1f760a0 100644 --- a/app/controllers/users/invitations_controller.rb +++ b/app/controllers/users/invitations_controller.rb @@ -13,9 +13,9 @@ super if flash[:alert].present? flash[:alert] = nil - # rubocop:disable LineLength + # rubocop:disable Metrics/LineLength flash[:notice] = _("You are already signed in as another user. Please log out to activate your invitation.") - # rubocop:enable LineLength + # rubocop:enable Metrics/LineLength end end diff --git a/app/controllers/users/omniauth_callbacks_controller.rb b/app/controllers/users/omniauth_callbacks_controller.rb index 5bb30a5..c57f1bd 100644 --- a/app/controllers/users/omniauth_callbacks_controller.rb +++ b/app/controllers/users/omniauth_callbacks_controller.rb @@ -55,11 +55,11 @@ if UserIdentifier.create(identifier_scheme: scheme, identifier: request.env["omniauth.auth"].uid, user: current_user) - # rubocop:disable LineLength + # rubocop:disable Metrics/LineLength flash[:notice] = _("Your account has been successfully linked to %{scheme}.") % { scheme: scheme.description } - # rubocop:enable LineLength + # rubocop:enable Metrics/LineLength else flash[:alert] = _("Unable to link your account to %{scheme}.") % { scheme: scheme.description @@ -73,9 +73,9 @@ identifier: request.env["omniauth.auth"].uid ).first if identifier.user.id != current_user.id - # rubocop:disable LineLength + # rubocop:disable Metrics/LineLength flash[:alert] = _("The current #{scheme.description} iD has been already linked to a user with email #{identifier.user.email}") - # rubocop:enable LineLength + # rubocop:enable Metrics/LineLength end # Otherwise, the identifier was found and it matches the one already associated diff --git a/app/helpers/perms_helper.rb b/app/helpers/perms_helper.rb index 38af331..bb5c739 100644 --- a/app/helpers/perms_helper.rb +++ b/app/helpers/perms_helper.rb @@ -10,7 +10,7 @@ use_api: _('API rights'), change_org_details: _('Manage organisation details'), grant_api_to_orgs: _('Grant API to organisations'), - review_org_plans: _('') + review_org_plans: _('Review plans') } end end diff --git a/app/helpers/template_helper.rb b/app/helpers/template_helper.rb index 2a8392e..bc4e639 100644 --- a/app/helpers/template_helper.rb +++ b/app/helpers/template_helper.rb @@ -40,10 +40,12 @@ # @param id [String] id for the link element def direct_link(template, hidden = false, text = nil, id = nil) params = { org_id: template.org.id, funder_id: '-1', template_id: template.id } - cls = text.nil? ? 'direct-link' : 'direct-link btn btn-default' + cls = text.nil? ? 'direct-link' : 'direct-link btn btn-default accessible' style = hidden ? 'display: none' : '' - link_to(plans_url(plan: params), method: :post, title: _('Create plan'), class: cls, id: id, style: style) do + link_to(plans_url(plan: params), + method: :post, title: _('Create plan'), + class: cls, id: id, style: style, target: "_self" ) do if text.nil? ''.html_safe else diff --git a/app/helpers/usage_helper.rb b/app/helpers/usage_helper.rb new file mode 100644 index 0000000..806c609 --- /dev/null +++ b/app/helpers/usage_helper.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +module UsageHelper + + def prep_data_for_yearly_users_chart(data:) + default_chart_prep(data: data) + end + + def prep_data_for_yearly_plans_chart(data:) + default_chart_prep(data: data) + end + + # The bar graph for 'plans by template' has multiple X variables (templates) + # for each point on the Y axis (date) so we need to format the information + # appropriately by passing along the labels for the Y axis and the datasets + # for the X axis + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + def prep_data_for_template_plans_chart(data:, subset: "by_template") + last_month = Date.today.last_month.end_of_month.strftime('%b-%y') + return { labels: [last_month], datasets: [] }.to_json if data.blank? || data.empty? + + datasets = {} + # Sort this chart's date by date desacending + data = data.map { |hash| JSON.parse(hash) } + .sort { |a, b| b["date"] <=> a["date"] } + # Extract all of the dates as month abbreviation - year (e.g. Dec-19) + labels = data.map { |rec| prep_date_for_charts(date: rec["date"]) } + + # Loop through the data and organize the datasets by template instead of date + data.each do |rec| + date = prep_date_for_charts(date: rec["date"]) + rec[subset].each do |template| + # We need a placeholder for each month/year - template combo. The + # default is to assume that there are zero plans for that month/year + template + dflt = { + label: template["name"], + backgroundColor: random_rgb, + data: labels.map { |lbl| { x: 0, y: lbl } } + } + + template_hash = datasets.fetch(template["name"], dflt) + + # Replace any of the month/year plan counts for this template IF it has + # any plans defined + template_hash[:data] = template_hash[:data].map do |data| + data[:y] == date ? { x: template["count"] + data[:x], y: data[:y] } : data + end + datasets[template["name"]] = template_hash + end + end + + # The Chart needs a separate labels array and a datasets hash + { + datasets: datasets.map { |_k, v| v }, + labels: labels + }.to_json + end + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + + def plans_per_template_ranges + [ + [_("Last month"), Date.today.last_month.end_of_month], + [_("Last 3 months"), Date.today.months_ago(3).end_of_month], + [_("Last 6 months"), Date.today.months_ago(6).end_of_month], + [_("Last 9 months"), Date.today.months_ago(9).end_of_month], + [_("Last 12 months"), Date.today.months_ago(12).end_of_month] + ] + end + + def default_chart_prep(data:) + hash = {} + data.map { |rec| JSON.parse(rec) }.each do |rec| + date = prep_date_for_charts(date: rec["date"]) + hash[date] = hash.fetch(date, 0) + rec["count"].to_i + end + hash + end + + def prep_date_for_charts(date:) + date.is_a?(Date) ? date.strftime("%b-%y") : Date.parse(date).strftime("%b-%y") + end + + def random_rgb + "rgb(#{rand(256)},#{rand(256)},#{rand(256)})" + end + +end diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js index a57afdc..c832e9c 100644 --- a/app/javascript/packs/application.js +++ b/app/javascript/packs/application.js @@ -4,6 +4,7 @@ // Generic JS that is applicable across multiple pages import '../utils/array'; +import '../utils/charts'; import '../utils/externalLink'; import '../utils/paginable'; import '../utils/panelHeading'; diff --git a/app/javascript/utils/charts.js b/app/javascript/utils/charts.js new file mode 100644 index 0000000..7dc776c --- /dev/null +++ b/app/javascript/utils/charts.js @@ -0,0 +1,113 @@ +import Chart from 'chart.js'; + +// Set Aspect Rate (width of X-axis/height of Y-axis) based on +// choice of selectedLastDayOfMonth in Time picker string value. Note aspect +export const getAspectRatio = (diffInMonths) => { + let ratio; + try { + switch (diffInMonths) { + case 0: + case 1: + ratio = 5; + break; + case 2: + case 3: + ratio = 3.5; + break; + case 4: + case 5: + case 6: + ratio = 2.5; + break; + case 7: + case 8: + case 9: + case 10: + ratio = 2; + break; + case 11: + case 12: + ratio = 1.5; + break; + default: + ratio = 0.9; + } + } catch (e) { + ratio = 0.9; + } + return ratio; +}; + +// Register a plugin for displaying a message for no data +export const initializeCharts = () => { + Chart.plugins.register({ + afterDraw: (chart) => { + if (chart.data.datasets.length === 0) { + const { ctx, width, height } = { + ctx: chart.chart.ctx, + width: chart.chart.width, + height: chart.chart.height, + }; + chart.clear(); + ctx.save(); + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.font = '25px bold'; + ctx.fillText('No data to display for selected time period', width / 2, height / 2); + ctx.restore(); + } + }, + }); +}; + +export const createChart = (selector, data, appendTolabel = '') => { + new Chart($(selector), { // eslint-disable-line no-new + type: 'bar', + data: { + labels: Object.keys(data), + datasets: [{ + data: Object.keys(data).map(k => data[k]), + backgroundColor: '#4F5253', + // TODO parameterised according to roadmap main colour instance + }], + }, + options: { + legend: { + display: false, + }, + tooltips: { + callbacks: { + label: tooltipItem => `${tooltipItem.yLabel} ${appendTolabel}`, + }, + }, + scales: { + yAxes: [{ + ticks: { min: 0, suggestedMax: 50 }, + }], + }, + }, + }); +}; + +export const drawHorizontalBar = (canvasSelector, data) => { + const aspectRatio = getAspectRatio(data.labels.length); + const chart = new Chart(canvasSelector, { // eslint-disable-line no-new + type: 'horizontalBar', + data, + options: { + responsive: true, + maintainAspectRatio: true, + aspectRatio, + scales: { + xAxes: [{ + ticks: { beginAtZero: true, stepSize: 10 }, + stacked: true, + }], + yAxes: [{ + stacked: true, + }], + }, + }, + }); + return chart; +}; diff --git a/app/javascript/utils/tinymce.js b/app/javascript/utils/tinymce.js deleted file mode 100644 index 45cebda..0000000 --- a/app/javascript/utils/tinymce.js +++ /dev/null @@ -1,123 +0,0 @@ -// Import TinyMCE -import tinymce from 'tinymce/tinymce'; -// Import TinyMCE theme -import 'tinymce/themes/modern/theme'; -// Plugins -import 'tinymce/plugins/table'; -import 'tinymce/plugins/lists'; -import 'tinymce/plugins/autoresize'; -import 'tinymce/plugins/link'; -import 'tinymce/plugins/paste'; -import 'tinymce/plugins/advlist'; -// Other dependencies -import { isObject, isString } from './isType'; - -// // Configuration extracted from -// // https://www.tinymce.com/docs/advanced/usage-with-module-loaders/ -export const defaultOptions = { - selector: '.tinymce', - statusbar: true, - menubar: false, - toolbar: 'bold italic | bullist numlist | link | table', - plugins: 'table autoresize link paste advlist lists', - advlist_bullet_styles: 'circle,disc,square', // Only disc bullets display on htmltoword - target_list: false, - elementpath: false, - resize: true, - autoresize_min_height: 130, - autoresize_bottom_margin: 10, - branding: false, - extended_valid_elements: 'iframe[tooltip] , a[href|target=_blank]', - paste_auto_cleanup_on_paste: true, - paste_remove_styles: true, - paste_retain_style_properties: 'none', - paste_convert_middot_lists: true, - paste_remove_styles_if_webkit: true, - paste_remove_spans: true, - paste_strip_class_attributes: 'all', - table_default_attributes: { - border: 1, - }, - // editorManager.baseURL is not resolved properly for IE since document.currentScript - // is not supported, see issue https://github.com/tinymce/tinymce/issues/358 - skin_url: '/tinymce/skins/lightgray', - content_css: ['/assets/blocks/_tinymce_content.css'], -}; -/* - This function is invoked anytime a new editor is initialised (e.g. Tinymce.init()) - and shrinks a tinymce editor to the minimum height specified at autoresize_min_height - editor's settings. Since there are cases that tinymce editor is loaded in the DOM - but has display:none style, the iframe associated gets the height of the screen's device - and using this function there is no need to wait until the tinymce gains focus to be autoresized. -*/ -const resizeEditors = (editors) => { - editors.forEach((editor) => { - $(editor.iframeElement).height(editor.settings.autoresize_min_height); - }); -}; - -export const Tinymce = { - /* - Initialises a tinymce editor given the object passed. If a non-valid object is passed, - the defaultOptions object is used instead - @param options - An object with tinyMCE properties - */ - init(options = {}) { - if (isObject(options)) { - tinymce.init(Object.assign({}, defaultOptions, options)).then(resizeEditors); - } else { - tinymce.init(defaultOptions).then(resizeEditors); - } - }, - /* - Finds any tinyMCE editor whose target element/textarea has the className passed - @param className - A string representing the class name of the tinyMCE editor - target element/textarea to look for - @return An Array of tinymce.Editor objects - */ - findEditorsByClassName(className) { - if (isString(className)) { - return tinymce.editors.reduce((acc, e) => { - if ($(e.getElement()).hasClass(className)) { - return acc.concat([e]); - } - return acc; - }, []); - } - return []; - }, - /* - Finds a tinyMCE editor whose target element/textarea has the id passed - @param id - A string representing the id of the tinyMCE editor target - element/textarea to look for - @return tinymce.Editor object, otherwise undefined - */ - findEditorById(id) { - if (isString(id)) { - return tinymce.editors.find(el => el.id === id); - } - return undefined; - }, - /* - Destroy every editor instance whose target element/textarea has the className passed. This - method executes for each editor the method defined at tinymce.Editor.destroy (e.g. https://www.tinymce.com/docs/api/tinymce/tinymce.editor/#destroy). - @param className - A string representing the class name of the tinyMCE editor - target element/textarea to look for - @return undefined - */ - destroyEditorsByClassName(className) { - const editors = this.findEditorsByClassName(className); - editors.forEach(ed => ed.destroy(false)); - }, - /* - Destroy an editor instance whose target element/textarea has HTML id passed. This method - executes tinymce.Editor.destroy (e.g. https://www.tinymce.com/docs/api/tinymce/tinymce.editor/#destroy) for a successfull id found. - @return undefined - */ - destroyEditorById(id) { - const editor = this.findEditorById(id); - if (editor) { - editor.destroy(false); - } - }, -}; diff --git a/app/javascript/utils/tinymce.js.erb b/app/javascript/utils/tinymce.js.erb new file mode 100644 index 0000000..99583a5 --- /dev/null +++ b/app/javascript/utils/tinymce.js.erb @@ -0,0 +1,148 @@ +// Import TinyMCE +import tinymce from 'tinymce/tinymce'; +// Import TinyMCE theme +import 'tinymce/themes/modern/theme'; +// Plugins +import 'tinymce/plugins/table'; +import 'tinymce/plugins/lists'; +import 'tinymce/plugins/autoresize'; +import 'tinymce/plugins/link'; +import 'tinymce/plugins/paste'; +import 'tinymce/plugins/advlist'; +// Other dependencies +import { isObject, isString } from './isType'; +// Pull in the rails helper functions +<% helpers = ActionController::Base.helpers %> + +// // Configuration extracted from +// // https://www.tinymce.com/docs/advanced/usage-with-module-loaders/ +export const defaultOptions = { + selector: '.tinymce', + statusbar: true, + menubar: false, + toolbar: 'bold italic | bullist numlist | link | table', + plugins: 'table autoresize link paste advlist lists', + browser_spellcheck: true, + advlist_bullet_styles: 'circle,disc,square', // Only disc bullets display on htmltoword + target_list: false, + elementpath: false, + resize: true, + autoresize_min_height: 130, + autoresize_bottom_margin: 10, + branding: false, + extended_valid_elements: 'iframe[tooltip] , a[href|target=_blank]', + paste_auto_cleanup_on_paste: true, + paste_remove_styles: true, + paste_retain_style_properties: 'none', + paste_convert_middot_lists: true, + paste_remove_styles_if_webkit: true, + paste_remove_spans: true, + paste_strip_class_attributes: 'all', + table_default_attributes: { + border: 1, + }, + // editorManager.baseURL is not resolved properly for IE since document.currentScript + // is not supported, see issue https://github.com/tinymce/tinymce/issues/358 + skin_url: '/tinymce/skins/lightgray', + content_css: ['<%= helpers.asset_path "/assets/blocks/_tinymce_content.css" %>'], +}; +/* + This function is invoked anytime a new editor is initialised (e.g. Tinymce.init()) + and shrinks a tinymce editor to the minimum height specified at autoresize_min_height + editor's settings. Since there are cases that tinymce editor is loaded in the DOM + but has display:none style, the iframe associated gets the height of the screen's device + and using this function there is no need to wait until the tinymce gains focus to be autoresized. +*/ +const resizeEditors = (editors) => { + editors.forEach((editor) => { + $(editor.iframeElement).height(editor.settings.autoresize_min_height); + }); +}; + +/* + This function is invoked after the Tinymce widget is initialized. It moves the + connection with the label from the hidden field (that the Tinymce writes to + behind the scenes) to the Tinymce iframe so that screen readers read the correct + label when the tinymce iframe receives focus. + */ +const attachLabelToIframe = (tinymceContext) => { + const iframe = $(tinymceContext).siblings('.mce-container').find('iframe'); + if (isObject(iframe)) { + const lbl = iframe.closest('form').find('label'); + if (isObject(lbl)) { + // Connect the label to the iframe + lbl.attr('for', iframe.attr('id')); + } + } +}; + +export const Tinymce = { + /* + Initialises a tinymce editor given the object passed. If a non-valid object is passed, + the defaultOptions object is used instead + @param options - An object with tinyMCE properties + */ + init(options = {}) { + if (isObject(options)) { + tinymce.init(Object.assign({}, defaultOptions, options)).then(resizeEditors); + } else { + tinymce.init(defaultOptions).then(resizeEditors); + } + + // Connect the label to the Tinymce iframe + $(options.selector).each((idx, el) => { + attachLabelToIframe(el); + }); + }, + /* + Finds any tinyMCE editor whose target element/textarea has the className passed + @param className - A string representing the class name of the tinyMCE editor + target element/textarea to look for + @return An Array of tinymce.Editor objects + */ + findEditorsByClassName(className) { + if (isString(className)) { + return tinymce.editors.reduce((acc, e) => { + if ($(e.getElement()).hasClass(className)) { + return acc.concat([e]); + } + return acc; + }, []); + } + return []; + }, + /* + Finds a tinyMCE editor whose target element/textarea has the id passed + @param id - A string representing the id of the tinyMCE editor target + element/textarea to look for + @return tinymce.Editor object, otherwise undefined + */ + findEditorById(id) { + if (isString(id)) { + return tinymce.editors.find(el => el.id === id); + } + return undefined; + }, + /* + Destroy every editor instance whose target element/textarea has the className passed. This + method executes for each editor the method defined at tinymce.Editor.destroy (e.g. https://www.tinymce.com/docs/api/tinymce/tinymce.editor/#destroy). + @param className - A string representing the class name of the tinyMCE editor + target element/textarea to look for + @return undefined + */ + destroyEditorsByClassName(className) { + const editors = this.findEditorsByClassName(className); + editors.forEach(ed => ed.destroy(false)); + }, + /* + Destroy an editor instance whose target element/textarea has HTML id passed. This method + executes tinymce.Editor.destroy (e.g. https://www.tinymce.com/docs/api/tinymce/tinymce.editor/#destroy) for a successfull id found. + @return undefined + */ + destroyEditorById(id) { + const editor = this.findEditorById(id); + if (editor) { + editor.destroy(false); + } + }, +}; diff --git a/app/javascript/views/answers/edit.js b/app/javascript/views/answers/edit.js index e766027..22f2836 100644 --- a/app/javascript/views/answers/edit.js +++ b/app/javascript/views/answers/edit.js @@ -3,7 +3,7 @@ isNumber, isString, } from '../../utils/isType'; -import { Tinymce } from '../../utils/tinymce'; +import { Tinymce } from '../../utils/tinymce.js.erb'; import debounce from '../../utils/debounce'; import datePicker from '../../utils/datePicker'; import TimeagoFactory from '../../utils/timeagoFactory'; @@ -54,6 +54,7 @@ $(`#answer-locking-${data.question.id}-research-output-${data.research_output.id}`).html(data.question.locking); } else { // When answer is NOT stale... $(`#answer-locking-${data.question.id}-research-output-${data.research_output.id}`).html(''); + $(`#answer-form-${data.question.id}-research-output-${data.research_output.id} .answer_id`).val(data.answer.id); if (isNumber(data.question.answer_lock_version)) { form.find('#answer_lock_version').val(data.question.answer_lock_version); } @@ -198,20 +199,34 @@ const targetState = target.prop('checked'); const parentTab = target.parents('.main_research_output'); const sectionContent = target.parents('.section-content'); + const url = target.data('target-url'); + const answerIds = []; // Set answers 'is_common' hidden checkbox to the same state // as the master checkbox // Used to indicate that answers from the first research output are common to all parentTab.find('.ans_is_common').each((i, el) => { - $(el).prop('checked', targetState); + $(el).val(targetState); }); - // Submit the answer after checking the hidden box + // Get the id of the answers if exist parentTab.find('.answer_id').each((i, el) => { if ($(el).val()) { - $(el).parent().trigger('submit'); + answerIds.push($(el).val()); } }); + $.ajax({ + method: 'post', + url, + data: { + answer_ids: answerIds, + is_common: targetState, + }, + }).done(() => { + parentTab.find('.common_changed').show().fadeOut(5000); + }).fail((error) => { + failCallback(error, target); + }); // Enable or disable research outputs tabs depending on 'is_common' state if (targetState) { @@ -261,6 +276,16 @@ }); }); + $('.research_outputs_tabs a[data-toggle="tab"]').on('shown.bs.tab', (e) => { + const researchOutputId = $(e.target).data('research-output'); + const tabsList = $(`.research_outputs_tabs a[data-research-output="${researchOutputId}"]`); + tabsList.each((idx, tab) => { + if (!$(tab).parent().hasClass('disabled')) { + $(tab).tab('show'); + } + }); + }); + $('body').on('click', '.add-record', (e) => { const currentField = $(e.target.closest('.field')); const clonedField = currentField.clone(true, true); diff --git a/app/javascript/views/devise/registrations/edit.js b/app/javascript/views/devise/registrations/edit.js index 5731a49..8b5457e 100644 --- a/app/javascript/views/devise/registrations/edit.js +++ b/app/javascript/views/devise/registrations/edit.js @@ -19,7 +19,7 @@ const originalEmail = $('#original_email').val(); const originalOrg = $('#original_org').val(); const email = $('#personal_details_registration_form [name="user[email]"]').val(); - const org = $('#personal_details_registration_form #user_org_id').val(); + const org = $('#personal_details_registration_form .selected-org').val(); const pwd = $('#password-confirmation input[name="user[current_password]"]').val(); const orgConfirm = $('#confirm_org_change').is(':checked'); let display = false; @@ -34,6 +34,7 @@ } } // If the orginalOrg is present and the selected Org has changed, show the confirmation box + console.log(originalOrg, org); if (isString(originalOrg) && isString(org)) { if (originalOrg !== org && !orgConfirm) { $('#org-change').removeClass('hide'); diff --git a/app/javascript/views/guidances/new_edit.js b/app/javascript/views/guidances/new_edit.js index 857ffa9..24fcc98 100644 --- a/app/javascript/views/guidances/new_edit.js +++ b/app/javascript/views/guidances/new_edit.js @@ -1,4 +1,4 @@ -import { Tinymce } from '../../utils/tinymce'; +import { Tinymce } from '../../utils/tinymce.js.erb'; $(() => { Tinymce.init({ selector: '#guidance-text' }); diff --git a/app/javascript/views/notes/index.js b/app/javascript/views/notes/index.js index 79321c7..77dbcb7 100644 --- a/app/javascript/views/notes/index.js +++ b/app/javascript/views/notes/index.js @@ -1,9 +1,9 @@ -import { Tinymce } from '../../utils/tinymce'; +import { Tinymce } from '../../utils/tinymce.js.erb'; import { isObject, isString } from '../../utils/isType'; import TimeagoFactory from '../../utils/timeagoFactory'; $(() => { - const defaultViewSelector = questionId => `#note_new${questionId}`; + const defaultViewSelector = (questionId, researchOutputId) => `#note_new${questionId}research-output${researchOutputId}`; const currentViewSelector = {}; /* currentViewSelector represents a map where each key is the question id and @@ -21,7 +21,8 @@ const initialiseCurrentViewSelector = () => { $('.note_new').each((i, e) => { const questionId = $(e).attr('data-question-id'); - putCurrentViewSelector(questionId, defaultViewSelector(questionId)); + const researchOutputId = $(e).attr('data-research-output-id'); + putCurrentViewSelector(questionId, defaultViewSelector(questionId, researchOutputId)); }); }; const success = (data) => { @@ -162,7 +163,7 @@ }; const initOrReload = (researchOutputId = null) => { if (researchOutputId) { - Tinymce.init({ selector: `${researchOutputId} .note` }); + Tinymce.init({ selector: '.note' }); } eventHandlers({ attachment: 'on' }); TimeagoFactory.render($('time.timeago')); diff --git a/app/javascript/views/org_admin/phases/new_edit.js b/app/javascript/views/org_admin/phases/new_edit.js index 49fde15..e02a9ac 100644 --- a/app/javascript/views/org_admin/phases/new_edit.js +++ b/app/javascript/views/org_admin/phases/new_edit.js @@ -1,5 +1,5 @@ import 'bootstrap-sass/assets/javascripts/bootstrap/collapse'; -import { Tinymce } from '../../../utils/tinymce'; +import { Tinymce } from '../../../utils/tinymce.js.erb'; import { isObject, isString } from '../../../utils/isType'; import getConstant from '../../../constants'; import expandCollapseAll from '../../../utils/expandCollapseAll'; diff --git a/app/javascript/views/org_admin/templates/edit.js b/app/javascript/views/org_admin/templates/edit.js index aaaf543..30e9e6d 100644 --- a/app/javascript/views/org_admin/templates/edit.js +++ b/app/javascript/views/org_admin/templates/edit.js @@ -1,4 +1,4 @@ -import { Tinymce } from '../../../utils/tinymce'; +import { Tinymce } from '../../../utils/tinymce.js.erb'; import { eachLinks } from '../../../utils/links'; import { isObject, isString } from '../../../utils/isType'; import { renderNotice, renderAlert } from '../../../utils/notificationHelper'; diff --git a/app/javascript/views/org_admin/templates/new.js b/app/javascript/views/org_admin/templates/new.js index 9dd7175..57a4b98 100644 --- a/app/javascript/views/org_admin/templates/new.js +++ b/app/javascript/views/org_admin/templates/new.js @@ -1,4 +1,4 @@ -import { Tinymce } from '../../../utils/tinymce'; +import { Tinymce } from '../../../utils/tinymce.js.erb'; import { eachLinks } from '../../../utils/links'; $(() => { diff --git a/app/javascript/views/orgs/admin_edit.js b/app/javascript/views/orgs/admin_edit.js index 907f62d..8de5dd3 100644 --- a/app/javascript/views/orgs/admin_edit.js +++ b/app/javascript/views/orgs/admin_edit.js @@ -1,7 +1,7 @@ // TODO: we need to be able to swap in the appropriate locale here import 'number-to-text/converters/en-us'; import { isObject } from '../../utils/isType'; -import { Tinymce } from '../../utils/tinymce'; +import { Tinymce } from '../../utils/tinymce.js.erb'; import { eachLinks } from '../../utils/links'; $(() => { diff --git a/app/javascript/views/plans/edit_details.js b/app/javascript/views/plans/edit_details.js index a550b24..b4da7b1 100644 --- a/app/javascript/views/plans/edit_details.js +++ b/app/javascript/views/plans/edit_details.js @@ -1,4 +1,4 @@ -import { Tinymce } from '../../utils/tinymce'; +import { Tinymce } from '../../utils/tinymce.js.erb'; import getConstant from '../../constants'; import 'bootstrap-3-typeahead'; diff --git a/app/javascript/views/plans/new.js b/app/javascript/views/plans/new.js index c627a58..50d526a 100644 --- a/app/javascript/views/plans/new.js +++ b/app/javascript/views/plans/new.js @@ -39,34 +39,33 @@ $('#plan_template_id option').attr('selected', 'true'); $('#multiple-templates').hide(); if ($('#plan_org_id').val() !== '-1') { - if (data.templates[0].default) { - $('#default-template').show(); - $('#single-template').hide(); - $('#create-btn').hide(); - } else { - if ($('#single-template .single-template-name').length) { - $('#single-template .single-template-name').html($('#single-template .single-template-name').html().replace('__template_title__', templateTitle)); - } - $('#create-btn').show(); - $('#single-template').show(); - $('#default-template').hide(); + if ($('#single-template .single-template-name').length) { + $('#single-template .single-template-name').html($('#single-template .single-template-name').html().replace('__template_title__', templateTitle)); } + $('#create-btn').show(); + $('#single-template').show(); + $('#no-template').hide(); + $('#default-template').hide(); } else if ($('#plan_funder_id').val() !== '-1') { if ($('#single-template .single-template-name').length) { $('#single-template .single-template-name').html($('#single-template .single-template-name').html().replace('__template_title__', templateTitle)); } $('#create-btn').show(); $('#single-template').show(); + $('#no-template').hide(); } } else { $('#multiple-templates').show(); + $('#no-template').hide(); $('#available-templates').fadeIn(); $('#single-template, #default-template').hide(); $('#create-btn').show(); } toggleSubmit(); } else { - error(); + $('#no-template').show(); + $('#single-template').hide(); + $('#default-template').hide(); } } }; @@ -174,7 +173,7 @@ const emptyTab = () => { // $('#plan_org_id').val('-1'); $('#plan_org_name').val(''); - $('#single-template, #default-template').hide(); + $('#single-template, #default-template, #no-template').hide(); }; // Empty combobox on second & third tab activation diff --git a/app/javascript/views/plans/research_outputs.js b/app/javascript/views/plans/research_outputs.js index 7ddd05e..ad95d24 100644 --- a/app/javascript/views/plans/research_outputs.js +++ b/app/javascript/views/plans/research_outputs.js @@ -11,6 +11,7 @@ $('#research-outputs').sortable({ + items: '.research-output-element:not(.inactive)', handle: '.research-output-actions .handle', stop: () => { $('#research-outputs .research-output-element').each(function callback(index) { diff --git a/app/javascript/views/super_admin/notifications/edit.js b/app/javascript/views/super_admin/notifications/edit.js index 3ab39cf..077b8d8 100644 --- a/app/javascript/views/super_admin/notifications/edit.js +++ b/app/javascript/views/super_admin/notifications/edit.js @@ -1,4 +1,4 @@ -import { Tinymce } from '../../../utils/tinymce'; +import { Tinymce } from '../../../utils/tinymce.js.erb'; $(() => { Tinymce.init({ selector: '.notification-text', forced_root_block: '' }); diff --git a/app/javascript/views/super_admin/static_pages/edit.js b/app/javascript/views/super_admin/static_pages/edit.js index d6eceb0..9c0e088 100644 --- a/app/javascript/views/super_admin/static_pages/edit.js +++ b/app/javascript/views/super_admin/static_pages/edit.js @@ -1,4 +1,4 @@ -import { Tinymce } from '../../../utils/tinymce'; +import { Tinymce } from '../../../utils/tinymce.js.erb'; import 'tinymce/plugins/code'; import 'tinymce/plugins/textcolor'; import 'tinymce/plugins/colorpicker'; diff --git a/app/javascript/views/super_admin/themes/new_edit.js b/app/javascript/views/super_admin/themes/new_edit.js index e03915a..c670dbc 100644 --- a/app/javascript/views/super_admin/themes/new_edit.js +++ b/app/javascript/views/super_admin/themes/new_edit.js @@ -1,4 +1,4 @@ -import { Tinymce } from '../../../utils/tinymce'; +import { Tinymce } from '../../../utils/tinymce.js.erb'; $(() => { Tinymce.init({ selector: '#theme_description' }); diff --git a/app/javascript/views/usage/index.js b/app/javascript/views/usage/index.js index ba71f8c..1877f90 100644 --- a/app/javascript/views/usage/index.js +++ b/app/javascript/views/usage/index.js @@ -1,324 +1,80 @@ -import moment from 'moment/moment'; -import Chart from 'chart.js'; +import { isObject, isUndefined } from '../../utils/isType'; +import { initializeCharts, createChart, drawHorizontalBar } from '../../utils/charts'; $(() => { - const usageFormSelector = '.usage_index'; - const apiToken = $(usageFormSelector).find('input[name="api_token"]').val(); - // Builds an object whose keys are the topic fro the select options and value its the value - // associated to the attribute data-url of each option - const topicToURL = $(`${usageFormSelector} select[name="topic"]`).find('option').map((i, el) => { - const topic = $(el); - return { [topic.val()]: topic.attr('data-url') }; - }).get() // An array of objects { topic: URL } - .reduce((acc, value) => $.extend(acc, value), {}); - const rangeDatesUpToLastYearFromNow = () => { - const getLastMonth = () => moment().subtract(1, 'month').clone(); - const rangeDates = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1].reduce((acc, v, i) => { - const id = getLastMonth().subtract(i, 'month').format('MMM-YY'); - acc[id] = { - start_date: getLastMonth().startOf('month').subtract(i, 'month').format('YYYY-MM-DD'), - end_date: getLastMonth().endOf('month').subtract(i, 'month').format('YYYY-MM-DD'), - id, - }; - return acc; - }, {}); - - return rangeDates; - }; - - // Register a plugin for displaying a message for no data - Chart.plugins.register({ - afterDraw: (chart) => { - if (chart.data.datasets.length === 0) { - const { ctx, width, height } = { - ctx: chart.chart.ctx, - width: chart.chart.width, - height: chart.chart.height, - }; - chart.clear(); - ctx.save(); - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.font = '25px bold'; - ctx.fillText('No data to display for selected time period', width / 2, height / 2); - ctx.restore(); - } - }, - }); - - const createChart = ({ selector, data, appendTolabel = '' } = {}) => { - new Chart($(selector), { // eslint-disable-line no-new - type: 'bar', - data: { - labels: Object.keys(data), - datasets: [{ - data: Object.keys(data).map(k => data[k]), - backgroundColor: '#4F5253', - // TODO parameterised according to roadmap main colour instance - }], - }, - options: { - legend: { - display: false, - }, - tooltips: { - callbacks: { - label: tooltipItem => `${tooltipItem.yLabel} ${appendTolabel}`, - }, - }, - scales: { - yAxes: [{ - ticks: { min: 0, suggestedMax: 50 }, - }], - }, - }, - }); - }; - /* - Submit event associated to the filter by dates form - */ - $(usageFormSelector).on('submit', (e) => { - e.preventDefault(); - const target = $(e.target); - const topic = target.find('select[name="topic"]').val(); - const orgId = target.find('select[name="org_id"]').val() || target.find('input[name="org_id"]').val(); - $('[data-topics]').hide(); // Hides data-topics container - $('[data-topic]').hide(); // Hides any data-topic specific - const ajaxSettings = ({ totals = false } = {}) => ({ - headers: { Authorization: `Token token="${apiToken}"` }, - url: topicToURL[topic], - data: totals ? { topic, org_id: orgId } : target.serialize(), - }); - // Awaits until both AJAX request responds. - // Note, the success handler is only executed if both AJAX requests return success - $.when($.ajax(ajaxSettings()), $.ajax(ajaxSettings({ totals: true }))).then( - (dataRangeSuccessCb, dataTotalsSuccessCb) => { - let dataRange = null; - let dataTotals = null; - if (dataRangeSuccessCb[0]) { // data is the first argument of the successCb ranges - const dataKeys = Object.keys(dataRangeSuccessCb[0]); - // We assume the dataRange is the first key of the object responded - dataRange = dataKeys.length > 0 ? dataRangeSuccessCb[0][dataKeys[0]] : null; - } - if (dataTotalsSuccessCb[0]) { // data is the first argument of the successCb for totals - const dataKeys = Object.keys(dataTotalsSuccessCb[0]); - // We assume the dataTotals is the first key of the object responded - dataTotals = dataKeys.length > 0 ? dataTotalsSuccessCb[0][dataKeys[0]] : null; - } - const dataTopics = $('[data-topics]'); - const views = $(`[data-topic="${topic}"]`); - dataRange !== null ? dataTopics.find('[data-range]').html(dataRange) : undefined; // eslint-disable-line no-unused-expressions - dataTotals !== null ? dataTopics.find('[data-totals]').html(dataTotals) : undefined; // eslint-disable-line no-unused-expressions - views.show(); - dataTopics.show(); - }, - ); // TODO request error handling - }); - /* - Click event associated to each Export button - */ - $('button.stat[data-url]').on('click', (e) => { - const rangeDates = rangeDatesUpToLastYearFromNow(); - $.ajax({ - headers: { Authorization: `Token token="${apiToken}"` }, - url: $(e.currentTarget).attr('data-url'), - data: { range_dates: rangeDates }, - }).then((data, statusText, jqXHR) => { - /* eslint-env browser */ - const blob = new Blob([data], { type: 'text/csv' }); - // Attemps to match the filename from the Content-Disposition header produced by the API - const match = /filename="([^"]*)"/.exec(jqXHR.getResponseHeader('Content-Disposition')); - const link = $('', { - href: URL.createObjectURL(blob), - download: match ? match[1] : 'export.csv', - }); - $('body').append(link); - link[0].click(); - link.remove(); - }); - }); - const yearlySuccesHandler = ({ data, selector } = {}) => { - const keys = Object.keys(data); // Keys are Month-Year strings and values might be [0...N] - if (keys.find(k => data[k] > 0)) { - createChart({ selector, data }); - } else { - $(selector).prev().show(); - } - }; - // Sends an AJAX request to our two current endpoints that generate yearly data - // (e.g. users_joined_api_v0_statistics_path, created_plans_api_v0_statistics_path ) - // and draws a barChart when success response is found - const initialise = () => { - // Only fire AJAX requests if topicToURL object has keys, i.e. topics mapping to URLs - if (Object.keys(topicToURL).length > 0) { - const rangeDates = rangeDatesUpToLastYearFromNow(); - $.ajax({ - headers: { Authorization: `Token token="${apiToken}"` }, - url: topicToURL.users, - data: { range_dates: rangeDates }, - }).then((data) => { - yearlySuccesHandler({ data, selector: '#yearly_users' }); - }); // TODO request error handling - $.ajax({ - headers: { Authorization: `Token token="${apiToken}"` }, - url: topicToURL.plans, - data: { range_dates: rangeDates }, - }).then((data) => { - yearlySuccesHandler({ data, selector: '#yearly_plans' }); - }); // TODO request error handling - } - }; - initialise(); -}); - -$(() => { - const drawnChart = {}; - const randomRgb = () => { - const { round, random } = Math; - const max = 255; - const f = () => round(random() * max); - return `rgb(${f()},${f()},${f()})`; - }; - const yAxisLabel = date => moment(date).format('MMM-YY'); - - const drawHorizontalBar = (canvasSelector, data, aspectRatio = 1) => { - const chart = new Chart(canvasSelector, { // eslint-disable-line no-new - type: 'horizontalBar', - data, - options: { - responsive: true, - maintainAspectRatio: true, - aspectRatio, - scales: { - xAxes: [{ - ticks: { beginAtZero: true, stepSize: 10 }, - stacked: true, - }], - yAxes: [{ - stacked: true, - }], - }, - }, - }); - return chart; - }; - - const buildData = (data, templateFilter) => { - const labels = data.map(current => yAxisLabel(current.date)); - const datasetsMap = data.reduce((acc, statCreatedPlan) => { - statCreatedPlan[`${templateFilter}_template`].forEach((template) => { - if (!acc[template.name]) { - acc[template.name] = { label: template.name, data: [], backgroundColor: randomRgb() }; - } - acc[template.name].data.push({ x: template.count, y: yAxisLabel(statCreatedPlan.date) }); - }); - return acc; - }, {}); - // const datasets = Object.keys(datasetsMap).map(key => datasetsMap[key]); - const compare = (a, b) => { - const aIndex = labels.indexOf(a.y); - const bIndex = labels.indexOf(b.y); - if (aIndex > bIndex) return 1; - if (aIndex < bIndex) return -1; - return 0; + // fns to handle the separator character menu + // for CSV download + const changeStatFnGen = (str) => { + const fn = (item) => { + /* eslint no-param-reassign: ["error", { "props": false }] */ + item.href = item.href.replace(/sep=.*/, str); }; - const datasets = Object.keys(datasetsMap).map((key) => { - const datasetByKey = datasetsMap[key]; - const availableMonths = datasetByKey.data.reduce((acc, value) => { - // month has y as key - acc.push(value.y); - return acc; - }, []); - // Find missing months in data - const missingMonths = labels.filter(month => !availableMonths.includes(month)); - // Add data for missing months with x value set to 0 - missingMonths.forEach(month => datasetByKey.data.push({ x: 0, y: month })); - datasetByKey.data = datasetByKey.data.sort(compare); - return datasetByKey; - }); - return { labels, datasets }; + return fn; }; - const fetch = (lastDayOfMonth, aspectRatio = 1, filter = null) => { - const selectElements = filter ? $('select[name="monthly_plans_by_template"]').filter(`[data-template-filter="${filter}"]`) : $('select[name="monthly_plans_by_template"]'); - selectElements.each((i, selectElem) => { - const baseUrl = $(selectElem).attr('data-url'); - const templateFilter = $(selectElem).attr('data-template-filter'); - $.ajax({ - url: `${baseUrl}?start_date=${lastDayOfMonth}&templates=${templateFilter}`, - }).then((data) => { - const chartData = buildData(data, templateFilter); - const canvasSelector = `#monthly_plans_using_${templateFilter}_template_canvas`; - if (drawnChart[templateFilter]) { - drawnChart[templateFilter].destroy(); - delete drawnChart[templateFilter]; - } - drawnChart[templateFilter] = drawHorizontalBar($(canvasSelector), chartData, aspectRatio); - }); - }); - }; - - // Set Aspect Rate (width of X-axis/height of Y-axis) based on - // choice of selectedLastDayOfMonth in Time picker string value. Note aspect - const getAspectRatio = (selectedLastDayOfMonth) => { - let aspectRatio; - try { - const now = new Date(); - const dateOfSelectedMonth = new Date(selectedLastDayOfMonth); - const diff = new Date(now.getTime() - dateOfSelectedMonth.getTime()); - const diffInMonths = diff.getUTCMonth(); - - switch (diffInMonths) { - case 0: - case 1: - aspectRatio = 5; - break; - case 2: - case 3: - aspectRatio = 3.5; - break; - case 4: - case 5: - case 6: - aspectRatio = 2.5; - break; - case 7: - case 8: - case 9: - case 10: - aspectRatio = 2; - break; - case 11: - case 12: - aspectRatio = 1.5; - break; - default: - aspectRatio = 0.9; - } - } catch (e) { - aspectRatio = 0.9; - } - - return aspectRatio; - }; - - const handler = (selectedMonth = null, templateFilter = null) => { - if (selectedMonth) { - const aspectRatio = getAspectRatio(selectedMonth); - fetch(selectedMonth, aspectRatio, templateFilter); - } else { - const month = $('select[name=monthly_plans_by_template]').val(); - const aspectRatio = getAspectRatio(month); - fetch(month, aspectRatio, templateFilter); - } - }; - - $('select[name=monthly_plans_by_template]').on('change', (e) => { - const selectedMonth = $(e.target).val(); - const templateFilter = $(e.target).attr('data-template-filter'); - e.preventDefault(); - handler(selectedMonth, templateFilter); + // attach listener to separator select menu + // on change look for "stat" elements and chnage their query param + document.getElementById('csv-field-sep').addEventListener('click', (e) => { + const statElems = document.getElementsByClassName('stat'); + const newSep = 'sep='.concat(encodeURIComponent(e.target.value)); + const changeStatFn = changeStatFnGen(newSep); + Array.from(statElems).forEach(changeStatFn); }); - handler(); + initializeCharts(); + + // Create the Users joined chart + if (!isUndefined($('#users_joined').val())) { + const usersData = JSON.parse($('#users_joined').val()); + if (isObject(usersData)) { + createChart('#yearly_users', usersData); + } + } + // Create the Plans created chart + if (!isUndefined($('#plans_created').val())) { + const plansData = JSON.parse($('#plans_created').val()); + if (isObject(plansData)) { + createChart('#yearly_plans', plansData); + } + } + // TODO: Most of these event listeners would not be necessary if JQuery and + // all other JS libraries were available to the js.erb files. Reevaluate + // this JS once we move to Rails 5 and properly configure webpacker + let drawnChartByTemplate = null; + const monthlyPlanTemplatesChart = document.getElementById('monthly_plans_by_template'); + // Add event listeners that draw and destroy the chart + if (isObject(monthlyPlanTemplatesChart)) { + monthlyPlanTemplatesChart.addEventListener('renderChart', (e) => { + drawnChartByTemplate = drawHorizontalBar($('#monthly_plans_by_template'), e.detail); + // Assigning the chart to a window variable here so that we can fire + // the events from the js.erb + window.templatePlansChart = document.getElementById('monthly_plans_by_template'); + }); + monthlyPlanTemplatesChart.addEventListener('destroyChart', () => { + if (drawnChartByTemplate) { + drawnChartByTemplate.destroy(); + } + }); + } + + const monthlyPlanUsingTemplatesChart = document.getElementById('monthly_plans_using_template'); + // Add event listeners that draw the chart if it exists + if (isObject(monthlyPlanUsingTemplatesChart)) { + monthlyPlanUsingTemplatesChart.addEventListener('renderChart', (e) => { + drawHorizontalBar($('#monthly_plans_using_template'), e.detail); + }); + } + + // Create the initial Plans per template chart if the chart exists + if (isObject(monthlyPlanTemplatesChart)) { + const templatePlansData = JSON.parse($('#plans_by_template').val()); + const drawPer = new CustomEvent('renderChart', { detail: templatePlansData }); + document.getElementById('monthly_plans_by_template').dispatchEvent(drawPer); + } + // Create the initial Plans using template chart if the chart exists + if (isObject(monthlyPlanUsingTemplatesChart)) { + const usingTemplatePlansData = JSON.parse($('#plans_using_template').val()); + const drawUsing = new CustomEvent('renderChart', { detail: usingTemplatePlansData }); + document.getElementById('monthly_plans_using_template').dispatchEvent(drawUsing); + } }); diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index f926652..4cfd04a 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -18,11 +18,12 @@ @role = role @user = user @inviter = inviter - subject = d_('dmpopidor', '%{user_name} has shared a Data Management Plan with you in %{tool_name}') % { - :user_name => @inviter.name(false), - :tool_name => Rails.configuration.branding[:application][:name] - } - FastGettext.with_locale FastGettext.default_locale do + + FastGettext.with_locale current_locale(@user) do + subject = d_('dmpopidor', '%{user_name} has shared a Data Management Plan with you in %{tool_name}') % { + :user_name => @inviter.name(false), + :tool_name => Rails.configuration.branding[:application][:name] + } mail(to: @role.user.email, subject: subject) end end @@ -31,7 +32,7 @@ @role = role @user = user if user.active? - FastGettext.with_locale FastGettext.default_locale do + FastGettext.with_locale current_locale(role.user) do mail(to: @role.user.email, subject: _('Changed permissions on a Data Management Plan in %{tool_name}') %{ :tool_name => Rails.configuration.branding[:application][:name] }) end @@ -43,7 +44,7 @@ @plan = plan @current_user = current_user if user.active? - FastGettext.with_locale FastGettext.default_locale do + FastGettext.with_locale current_locale(@user) do mail(to: @user.email, subject: "#{_('Permissions removed on a DMP in %{tool_name}') %{ :tool_name => Rails.configuration.branding[:application][:name] }}") end @@ -58,7 +59,7 @@ @plan = plan @recipient = recipient - FastGettext.with_locale FastGettext.default_locale do + FastGettext.with_locale current_locale(recipient) do mail(to: recipient.email, subject: _("%{application_name}: %{user_name} requested feedback on a plan") % {application_name: Rails.configuration.branding[:application][:name], user_name: @user.name(false)}) end @@ -71,7 +72,7 @@ @plan = plan @phase = plan.phases.first if recipient.active? - FastGettext.with_locale FastGettext.default_locale do + FastGettext.with_locale current_locale(recipient) do mail(to: recipient.email, from: requestor.org.contact_email, subject: _("%{application_name}: Expert feedback has been provided for %{plan_title}") % {application_name: Rails.configuration.branding[:application][:name], plan_title: @plan.title}) @@ -103,7 +104,7 @@ @user = user @plan = plan if user.active? - FastGettext.with_locale FastGettext.default_locale do + FastGettext.with_locale current_locale(user) do mail(to: @user.email, subject: _('DMP Visibility Changed: %{plan_title}') %{ :plan_title => @plan.title }) end @@ -121,8 +122,8 @@ @commenter = commenter @plan = plan @answer = answer - FastGettext.with_locale FastGettext.default_locale do - mail(to: plan.owner.email, subject: + FastGettext.with_locale current_locale(owner) do + mail(to: owner.email, subject: _('%{tool_name}: A new comment was added to %{plan_title}') %{ :tool_name => Rails.configuration.branding[:application][:name], :plan_title => plan.title }) end end @@ -132,7 +133,7 @@ def admin_privileges(user) @user = user if user.active? - FastGettext.with_locale FastGettext.default_locale do + FastGettext.with_locale current_locale(@user) do mail(to: user.email, subject: _('Administrator privileges granted in %{tool_name}') %{ :tool_name => Rails.configuration.branding[:application][:name] }) end @@ -142,7 +143,7 @@ def anonymization_warning(user) @user = user @end_date = (@user.last_sign_in_at + 5.years).to_date - FastGettext.with_locale FastGettext.default_locale do + FastGettext.with_locale current_locale(@user) do mail(to: @user.email, subject: d_('dmpopidor', 'Account expiration in %{tool_name}') %{ :tool_name => Rails.configuration.branding[:application][:name] }) end @@ -150,10 +151,17 @@ def anonymization_notice(user) @user = user - FastGettext.with_locale FastGettext.default_locale do + FastGettext.with_locale current_locale(@user) do mail(to: @user.email, subject: d_('dmpopidor', 'Account expired in %{tool_name}') %{ :tool_name => Rails.configuration.branding[:application][:name] }) end end + + private + + def current_locale(user) + user.get_locale.nil? ? FastGettext.default_locale : user.get_locale + end + end diff --git a/app/models/annotation.rb b/app/models/annotation.rb index 6a9ee9c..5199170 100644 --- a/app/models/annotation.rb +++ b/app/models/annotation.rb @@ -52,7 +52,6 @@ uniqueness: { message: UNIQUENESS_MESSAGE, scope: [:question_id, :org_id] } - # ================= # = Class Methods = # ================= diff --git a/app/models/answer.rb b/app/models/answer.rb index 9629cc3..6b1a534 100644 --- a/app/models/answer.rb +++ b/app/models/answer.rb @@ -57,9 +57,10 @@ validates :user, presence: { message: PRESENCE_MESSAGE } - validates :question, presence: { message: PRESENCE_MESSAGE }#, - # uniqueness: { message: UNIQUENESS_MESSAGE, - # scope: :plan_id } + validates :question, presence: { message: PRESENCE_MESSAGE } + + validates :research_output, presence: { message: PRESENCE_MESSAGE } + # ============= # = Callbacks = diff --git a/app/models/concerns/exportable_plan.rb b/app/models/concerns/exportable_plan.rb index 7dee211..6132f63 100644 --- a/app/models/concerns/exportable_plan.rb +++ b/app/models/concerns/exportable_plan.rb @@ -2,6 +2,8 @@ module ExportablePlan + prepend Dmpopidor::Concerns::ExportablePlan + def as_pdf(coversheet = false) prepare(coversheet) end @@ -86,6 +88,7 @@ hash end + # SEE MODULE def prepare_coversheet hash = {} # name of owner and any co-owners @@ -99,9 +102,7 @@ hash[:affiliation] = self.owner.present? ? self.owner.org.name : "" # set the funder name - hash[:funder] = self.funder_name.present? ? - self.funder_name : (self.template.org.present? ? - self.template.org.name : "") + hash[:funder] = self.funder_name.present? ? self.funder_name : "" # set the template name and customizer name if applicable hash[:template] = self.template.title diff --git a/app/models/org.rb b/app/models/org.rb index 590fbb5..61750ce 100644 --- a/app/models/org.rb +++ b/app/models/org.rb @@ -23,6 +23,7 @@ # feedback_enabled :boolean default("false") # feedback_email_subject :string # feedback_email_msg :text +# active :boolean default("true") # # Indexes # diff --git a/app/models/plan.rb b/app/models/plan.rb index 0d24ce9..db1608b 100644 --- a/app/models/plan.rb +++ b/app/models/plan.rb @@ -104,6 +104,8 @@ has_many :roles + belongs_to :feedback_requestor, class_name: "User", :foreign_key => 'feedback_requestor' + # RESEARCH OUTPUTS has_many :research_outputs, dependent: :destroy, inverse_of: :plan do # Returns the default research output @@ -187,6 +189,17 @@ ) } + scope :org_admin_visible, -> (user) { + plan_ids = Role.where(active: true, user_id: user.id).pluck(:plan_id) + + includes(:template, roles: :user) + .where(id: plan_ids, visibility: [ + visibilities[:administrator_visible], + visibilities[:organisationally_visible], + visibilities[:publicly_visible] + ]) + } + scope :search, lambda { |term| search_pattern = "%#{term}%" joins(:template) @@ -236,6 +249,7 @@ def self.deep_copy(plan) plan_copy = plan.dup plan_copy.title = "Copy of " + plan.title + plan_copy.feedback_requested = false plan_copy.save! plan.research_outputs.each do |research_output| research_output_copy = ResearchOutput.deep_copy(research_output) @@ -311,6 +325,7 @@ # emails confirmation messages to owners # emails org admins and org contact # adds org admins to plan with the 'reviewer' Role + # SEE MODULE def request_feedback(user) Plan.transaction do begin @@ -337,6 +352,7 @@ # Finalizes the feedback for the plan: Emails confirmation messages to owners # sets flag on plans.feedback_requested to false removes org admins from the # 'reviewer' Role for the Plan. + # SEE MODULE def complete_feedback(org_admin) Plan.transaction do begin @@ -378,19 +394,17 @@ # # Returns Boolean def readable_by?(user_id) + return true if commentable_by?(user_id) current_user = User.find(user_id) - if current_user.present? - # If the user is a super admin and the config allows for supers to view plans - if current_user.can_super_admin? && - Branding.fetch(:service_configuration, :plans, :super_admins_read_all) - true - # If the user is an org admin and the config allows for org admins to view plans - elsif current_user.can_org_admin? && - Branding.fetch(:service_configuration, :plans, :org_admins_read_all) - owner_and_coowners.map(&:org_id).include?(current_user.org_id) - else - commentable_by?(user_id) - end + return false unless current_user.present? + # If the user is a super admin and the config allows for supers to view plans + if current_user.can_super_admin? && + Branding.fetch(:service_configuration, :plans, :super_admins_read_all) + true + # If the user is an org admin and the config allows for org admins to view plans + elsif current_user.can_org_admin? && + Branding.fetch(:service_configuration, :plans, :org_admins_read_all) + owner_and_coowners.map(&:org_id).include?(current_user.org_id) else false end @@ -419,9 +433,11 @@ # user_id - The Integer id for the user # # Returns Boolean + # SEE MODULE def reviewable_by?(user_id) reviewer = User.find(user_id) feedback_requested? && + reviewer.present? && reviewer.org_id == owner.org_id && reviewer.can_review_plans? end diff --git a/app/models/research_output.rb b/app/models/research_output.rb index c597a0c..6456daa 100644 --- a/app/models/research_output.rb +++ b/app/models/research_output.rb @@ -46,6 +46,8 @@ validates :type, presence: { message: PRESENCE_MESSAGE } + validates :plan, presence: { message: PRESENCE_MESSAGE } + # ========== # = Scopes = diff --git a/app/models/stat.rb b/app/models/stat.rb index 5245406..b716f39 100644 --- a/app/models/stat.rb +++ b/app/models/stat.rb @@ -23,13 +23,17 @@ class << self - def to_csv(stats) + def to_csv(stats, sep=",") data = stats.map do |stat| { date: stat.date, count: stat.count } end - Csvable.from_array_of_hashes(data) + Csvable.from_array_of_hashes(data, sep) end end + def to_json(methods: nil) + super(only: %i[count date], methods: methods) + end + end diff --git a/app/models/stat_created_plan.rb b/app/models/stat_created_plan.rb index d0fdb17..1ad555b 100644 --- a/app/models/stat_created_plan.rb +++ b/app/models/stat_created_plan.rb @@ -4,7 +4,7 @@ # Table name: stats # # id :integer not null, primary key -# count :integer default("0") +# count :integer default(0) # date :date not null # type :string not null # org_id :integer @@ -19,41 +19,41 @@ serialize :details, JSON - def any_template - if self.details.present? - any_template = self.details["any_template"] - end - return [] unless any_template.present? - any_template + def by_template + parse_details.fetch("by_template", []) end - def org_template - if self.details.present? - org_template = self.details["org_template"] - end - return [] unless org_template.present? - org_template + def using_template + parse_details.fetch("using_template", []) + end + + def to_json(options = nil) + super(methods: [:by_template, :using_template]) + end + + def parse_details + return JSON.parse({}) unless details.present? + + json = details.is_a?(String) ? JSON.parse(details) : details end class << self - def to_csv(created_plans, details: { any_template: false, org_template: false,}) - if details[:any_template] - to_csv_by_template(created_plans, "any_template") - elsif details[:org_template] - to_csv_by_template(created_plans, "org_template") - else - super(created_plans) + def to_csv(created_plans, details: { by_template: false, sep: "," }) + if details[:by_template] + to_csv_by_template(created_plans, details[:sep]) + else + super(created_plans, details[:sep]) end end private - def to_csv_by_template(created_plans, template_filter) + def to_csv_by_template(created_plans, sep = ",") template_names = lambda do |created_plans| unique = Set.new created_plans.each do |created_plan| - created_plan.details&.fetch(template_filter, [])&.each do |name_count| + created_plan.by_template&.each do |name_count| unique.add(name_count.fetch("name")) end end @@ -66,13 +66,13 @@ acc[name] = 0 acc end - created_plan.details&.fetch(template_filter, [])&.each do |name_count| + created_plan.by_template&.each do |name_count| tuple[name_count.fetch("name")] = name_count.fetch("count") end tuple[:Count] = created_plan.count tuple end - Csvable.from_array_of_hashes(data, false) + Csvable.from_array_of_hashes(data, false, sep) end end diff --git a/app/models/stat_created_plan/create_or_update.rb b/app/models/stat_created_plan/create_or_update.rb index 6af2b43..ece95dc 100644 --- a/app/models/stat_created_plan/create_or_update.rb +++ b/app/models/stat_created_plan/create_or_update.rb @@ -8,13 +8,13 @@ def do(start_date:, end_date:, org:) count = count_plans(start_date: start_date, end_date: end_date, org: org) - any_template = any_template(start_date: start_date, end_date: end_date, org: org) - org_template = org_template(start_date: start_date, end_date: end_date, org: org) + by_template = plan_statistics(start_date: start_date, end_date: end_date, org: org) + using_template = plan_statistics(start_date: start_date, end_date: end_date, org: org, own_templates: true) attrs = { date: end_date.to_date, org_id: org.id, count: count, - details: { any_template: any_template, org_template: org_template } + details: { by_template: by_template, using_template: using_template } } stat_created_plan = StatCreatedPlan.find_by( date: attrs[:date], @@ -38,6 +38,10 @@ Plan.where(plans: { created_at: start_date..end_date }) end + def own_template_plans(org) + Plan.joins(:template).where(templates: { org_id: org.id }) + end + def count_plans(start_date:, end_date:, org:) Role.joins(:plan, :user) .administrator @@ -48,13 +52,16 @@ .count end - def any_template(start_date:, end_date:, org:) - roleable_plan_ids = Role.joins([:plan, :user]) + def plan_statistics(start_date:, end_date:, org:, own_templates: false) + roleable_plans = Role.joins([:plan, :user]) .administrator - .merge(users(org)) .merge(plans(start_date: start_date, end_date: end_date)) - .pluck(:plan_id) - .uniq + if own_templates + roleable_plans = roleable_plans.merge(own_template_plans(org)) + else + roleable_plans = roleable_plans.merge(users(org)) + end + roleable_plan_ids = roleable_plans.pluck(:plan_id).uniq template_counts = Plan.joins(:template).where(id: roleable_plan_ids) .group("templates.family_id").count @@ -68,22 +75,6 @@ end end - def org_template(start_date:, end_date:, org:) - org_templates_ids = org.templates.pluck(:id) - - template_counts = Plan.joins(:template).where(template_id: org_templates_ids) - .merge(plans(start_date: start_date, end_date: end_date)) - .group("templates.family_id").count - most_recent_versions = Template.where(family_id: template_counts.keys) - .group(:family_id).maximum("version") - most_recent_versions = most_recent_versions.map { |k, v| "#{k}=#{v}" } - template_names = Template.where("CONCAT(family_id, '=', version) IN (?)", - most_recent_versions).pluck(:family_id, :title) - template_names.map do |t| - { name: t[1], count: template_counts[t[0]] } - end - end - end end diff --git a/app/models/stat_exported_plan.rb b/app/models/stat_exported_plan.rb new file mode 100644 index 0000000..0f11074 --- /dev/null +++ b/app/models/stat_exported_plan.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: stats +# +# id :integer not null, primary key +# count :integer default(0) +# date :date not null +# details :text +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# org_id :integer +# + +class StatExportedPlan < Stat + + class << self + + def to_csv(exported_plans) + Stat.to_csv(exported_plans) + end + + end + +end diff --git a/app/models/stat_exported_plan/create_or_update.rb b/app/models/stat_exported_plan/create_or_update.rb new file mode 100644 index 0000000..a88b123 --- /dev/null +++ b/app/models/stat_exported_plan/create_or_update.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +class StatExportedPlan + + class CreateOrUpdate + + class << self + + def do(start_date:, end_date:, org:) + count = exported_plans(start_date: start_date, end_date: end_date, org_id: org.id) + attrs = { date: end_date.to_date, count: count, org_id: org.id } + + stat_exported_plan = StatExportedPlan.find_by( + date: attrs[:date], + org_id: attrs[:org_id] + ) + + if stat_exported_plan.present? + stat_exported_plan.update(attrs) + else + StatExportedPlan.create(attrs) + end + end + + private + + def users(org_id) + User.where(users: {org_id: org_id }) + end + + def org_plan_ids(org_id) + Role.joins(:user) + .creator + .merge(users(org_id)) + .pluck(:plan_id) + .uniq + end + + def exported_plans(start_date:, end_date:, org_id:) + ExportedPlan.where(plan_id: org_plan_ids(org_id)) + .where(created_at: start_date..end_date) + .count + end + + end + + end + +end diff --git a/app/models/stat_shared_plan.rb b/app/models/stat_shared_plan.rb new file mode 100644 index 0000000..b0ffe10 --- /dev/null +++ b/app/models/stat_shared_plan.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: stats +# +# id :integer not null, primary key +# count :integer default(0) +# date :date not null +# details :text +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# org_id :integer +# + +class StatSharedPlan < Stat + + class << self + + def to_csv(shared_plans) + Stat.to_csv(shared_plans) + end + + end + +end diff --git a/app/models/stat_shared_plan/create_or_update.rb b/app/models/stat_shared_plan/create_or_update.rb new file mode 100644 index 0000000..3022d1e --- /dev/null +++ b/app/models/stat_shared_plan/create_or_update.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +class StatSharedPlan + + class CreateOrUpdate + + class << self + + def do(start_date:, end_date:, org:) + count = shared_plans(start_date: start_date, end_date: end_date, org_id: org.id) + attrs = { date: end_date.to_date, count: count, org_id: org.id } + + stat_shared_plan = StatSharedPlan.find_by( + date: attrs[:date], + org_id: attrs[:org_id] + ) + + if stat_shared_plan.present? + stat_shared_plan.update(attrs) + else + StatSharedPlan.create(attrs) + end + end + + private + + def users(org_id) + User.where(users: {org_id: org_id }) + end + + def org_plan_ids(org_id) + Role.joins(:user) + .creator + .merge(users(org_id)) + .pluck(:plan_id) + .uniq + end + + def shared_plans(start_date:, end_date:, org_id:) + Role.not_creator + .where(plan_id: org_plan_ids(org_id)) + .where(created_at: start_date..end_date) + .count + end + + end + + end + +end diff --git a/app/models/user.rb b/app/models/user.rb index 7b7bbd0..3b20ad6 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -50,6 +50,7 @@ include ValidationMessages include ValidationValues extend UniqueRandom + prepend Dmpopidor::Models::User ## # Devise @@ -148,7 +149,7 @@ # = Callbacks = # ============= - before_update :clear_other_organisation, if: :org_id_changed? + before_update :clear_other_organisation, :if => proc { org_id_changed? && org_id != Org.find_by(is_other: true).id } before_update :clear_department_id, if: :org_id_changed? @@ -384,22 +385,12 @@ notifications << notification if notification.dismissable? end - # Anonymize a user (by removing its personnal data and deactivating its account) - def anonymize - copy = dup - - update(firstname: 'anonymous', surname: 'user', email: "anonymous#{id}@opidor.fr", last_sign_in_at: nil, encrypted_password: nil, active: false) - - if save - Rails.logger.info "User #{id} anonymized" - p "User #{id} anonymized" - UserMailer.anonymization_notice(copy).deliver_now - end - end + # remove personal data from the user account and save # leave account in-place, with org for statistics (until we refactor those) # # Returns boolean + # SEE MODULE def archive self.firstname = 'Deleted' self.surname = 'User' diff --git a/app/models/user/at_csv.rb b/app/models/user/at_csv.rb index 35a4689..42e6d6c 100644 --- a/app/models/user/at_csv.rb +++ b/app/models/user/at_csv.rb @@ -1,7 +1,7 @@ class User class AtCsv - HEADERS = ['Name', 'E-Mail', 'Created Date', 'Last Activity', 'Plans', 'Current Privileges', 'Active'] + HEADERS = ['Name', 'E-Mail', 'Created Date', 'Last Activity', 'Plans', 'Current Privileges', 'Department', 'Active'] def initialize(users) @users = users @@ -16,6 +16,7 @@ created = I18n.l user.created_at.to_date, format: :csv last_activity = I18n.l user.updated_at.to_date, format: :csv plans = user.plans.size + department = user.department ? user.department.name : '' active = user.active ? 'Yes' : 'No' if user.can_super_admin? @@ -26,10 +27,10 @@ current_privileges = '' end - csv << [ name, email, created, last_activity, plans, current_privileges, active ] + csv << [ name, email, created, last_activity, plans, current_privileges, department, active ] end end end end -end \ No newline at end of file +end diff --git a/app/policies/note_policy.rb b/app/policies/note_policy.rb index 972e584..3e7cf62 100644 --- a/app/policies/note_policy.rb +++ b/app/policies/note_policy.rb @@ -13,7 +13,7 @@ end def update? - Plan.find(@note.answer.plan_id).commentable_by?(@user.id) && @note.user_id = @user.id + Plan.find(@note.answer.plan_id).commentable_by?(@user.id) && @note.user_id == @user.id end def archive? diff --git a/app/policies/role_policy.rb b/app/policies/role_policy.rb index 8570ba1..5c234ad 100644 --- a/app/policies/role_policy.rb +++ b/app/policies/role_policy.rb @@ -21,6 +21,6 @@ end def deactivate? - @role.user_id = @user.id + @role.user_id == @user.id end -end \ No newline at end of file +end diff --git a/app/policies/usage_policy.rb b/app/policies/usage_policy.rb new file mode 100644 index 0000000..88d1d92 --- /dev/null +++ b/app/policies/usage_policy.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +# Disabling this rubocop check because this is the recommended approach to having +# a policy that is not associated with a model (per the pundit README) +# rubocop:disable Style/StructInheritance +class UsagePolicy < Struct.new(:user, :usage) + attr_reader :user + + def initialize(user, _usage) + raise Pundit::NotAuthorizedError, "must be logged in" unless user + + @user = user + end + + def index? + @user.can_org_admin? + end + + def plans_by_template? + @user.can_org_admin? + end + + def global_statistics? + @user.can_super_admin? + end + + def org_statistics? + @user.can_org_admin? + end + + def all_plans_by_template? + @user.can_org_admin? + end + + def yearly_users? + @user.can_org_admin? + end + + def yearly_plans? + @user.can_org_admin? + end + + def filter? + @user.can_org_admin? + end +end +# rubocop:enable Style/StructInheritance diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index 5293585..a73db53 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -65,6 +65,10 @@ signed_in_user.can_super_admin? end + def org_admin_other_user? + signed_in_user.can_super_admin? || signed_in_user.can_org_admin? + end + class Scope < Scope def resolve scope.where(org_id: user.org_id) diff --git a/app/services/org/create_created_plan_service.rb b/app/services/org/create_created_plan_service.rb index c668a77..c1cd442 100644 --- a/app/services/org/create_created_plan_service.rb +++ b/app/services/org/create_created_plan_service.rb @@ -1,15 +1,25 @@ # frozen_string_literal: true +#import statements fix Circular dependancy errors due to threading +import OrgDateRangeable +import StatCreatedPlan +import StatCreatedPlan::CreateOrUpdate +import Role +import User +import Plan +import Perm +import Template + class Org class CreateCreatedPlanService class << self - def call(org = nil) + def call(org = nil, threads: 0) orgs = org.nil? ? Org.all : [org] - orgs.each do |org| + Parallel.each(orgs, in_threads: threads) do |org| OrgDateRangeable.split_months_from_creation(org) do |start_date, end_date| StatCreatedPlan::CreateOrUpdate.do( start_date: start_date, diff --git a/app/services/org/create_exported_plan_service.rb b/app/services/org/create_exported_plan_service.rb new file mode 100644 index 0000000..0d1de4c --- /dev/null +++ b/app/services/org/create_exported_plan_service.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +#import statements fix Circular dependancy errors +import OrgDateRangeable +import StatExportedPlan +import StatExportedPlan::CreateOrUpdate +import Role +import User +import ExportedPlan + +class Org + + class CreateExportedPlanService + + class << self + + def call(org = nil, threads: 0) + orgs = org.nil? ? Org.all : [org] + + Parallel.each(orgs, in_threads: threads) do |org| + OrgDateRangeable.split_months_from_creation(org) do |start_date, end_date| + StatExportedPlan::CreateOrUpdate.do( + start_date: start_date, + end_date: end_date, + org: org + ) + end + end + end + + end + + end + +end diff --git a/app/services/org/create_joined_user_service.rb b/app/services/org/create_joined_user_service.rb index e4842eb..44f8ed3 100644 --- a/app/services/org/create_joined_user_service.rb +++ b/app/services/org/create_joined_user_service.rb @@ -1,14 +1,21 @@ # frozen_string_literal: true +#import statements fix Circular dependancy errors due to threading +import OrgDateRangeable +import StatJoinedUser +import StatJoinedUser::CreateOrUpdate +import User + class Org class CreateJoinedUserService class << self - def call(org = nil) + def call(org = nil, threads: 0) orgs = org.nil? ? Org.all : [org] - orgs.each do |org| + + Parallel.each(orgs, in_threads: threads) do |org| OrgDateRangeable.split_months_from_creation(org) do |start_date, end_date| StatJoinedUser::CreateOrUpdate.do( start_date: start_date, @@ -17,6 +24,7 @@ ) end end + # pp StatJoinedUser.where.not(count: 0) end end diff --git a/app/services/org/create_last_month_created_plan_service.rb b/app/services/org/create_last_month_created_plan_service.rb index 8093862..4acea4a 100644 --- a/app/services/org/create_last_month_created_plan_service.rb +++ b/app/services/org/create_last_month_created_plan_service.rb @@ -1,15 +1,26 @@ # frozen_string_literal: true +#import statements fix Circular dependancy errors due to threading +import OrgDateRangeable +import StatCreatedPlan +import StatCreatedPlan::CreateOrUpdate +import Role +import User +import Plan +import Perm +import Template + + class Org class CreateLastMonthCreatedPlanService class << self - def call(org = nil) + def call(org = nil, threads: 0) orgs = org.nil? ? Org.all : [org] - orgs.each do |org| + Parallel.each(orgs, in_threads: threads) do |org| months = OrgDateRangeable.split_months_from_creation(org) last = months.last if last.present? diff --git a/app/services/org/create_last_month_exported_plan_service.rb b/app/services/org/create_last_month_exported_plan_service.rb new file mode 100644 index 0000000..50ff09f --- /dev/null +++ b/app/services/org/create_last_month_exported_plan_service.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +#import statements fix Circular dependancy errors +import OrgDateRangeable +import StatExportedPlan +import StatExportedPlan::CreateOrUpdate +import Role +import User +import ExportedPlan + +class Org + + class CreateLastMonthExportedPlanService + + class << self + + def call(org = nil, threads: 0) + orgs = org.nil? ? Org.all : [org] + + Parallel.each(orgs, in_threads: threads) do |org| + months = OrgDateRangeable.split_months_from_creation(org) + last = months.last + if last.present? + StatExportedPlan::CreateOrUpdate.do( + start_date: last[:start_date], + end_date: last[:end_date], + org: org + ) + end + end + end + + end + + end + +end diff --git a/app/services/org/create_last_month_joined_user_service.rb b/app/services/org/create_last_month_joined_user_service.rb index 026dfd8..7b53534 100644 --- a/app/services/org/create_last_month_joined_user_service.rb +++ b/app/services/org/create_last_month_joined_user_service.rb @@ -1,14 +1,21 @@ # frozen_string_literal: true +#import statements fix Circular dependancy errors due to threading +import OrgDateRangeable +import StatJoinedUser +import StatJoinedUser::CreateOrUpdate +import User + class Org class CreateLastMonthJoinedUserService class << self - def call(org = nil) + def call(org = nil, threads: 0) orgs = org.nil? ? ::Org.all : [org] - orgs.each do |org| + + Parallel.each(orgs, in_threads: threads) do |org| months = OrgDateRangeable.split_months_from_creation(org) last = months.last if last.present? diff --git a/app/services/org/create_last_month_shared_plan_service.rb b/app/services/org/create_last_month_shared_plan_service.rb new file mode 100644 index 0000000..9eba8d6 --- /dev/null +++ b/app/services/org/create_last_month_shared_plan_service.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +#import statements fix Circular dependancy errors due to threading +import OrgDateRangeable +import StatSharedPlan +import StatSharedPlan::CreateOrUpdate +import User +import Role + +class Org + + class CreateLastMonthSharedPlanService + + class << self + + def call(org = nil, threads: 0) + orgs = org.nil? ? Org.all : [org] + + Parallel.each(orgs, in_threads: threads) do |org| + months = OrgDateRangeable.split_months_from_creation(org) + last = months.last + if last.present? + StatSharedPlan::CreateOrUpdate.do( + start_date: last[:start_date], + end_date: last[:end_date], + org: org + ) + end + end + end + + end + + end + +end diff --git a/app/services/org/create_shared_plan_service.rb b/app/services/org/create_shared_plan_service.rb new file mode 100644 index 0000000..b784235 --- /dev/null +++ b/app/services/org/create_shared_plan_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +#import statements fix Circular dependancy errors due to threading +import OrgDateRangeable +import StatSharedPlan +import StatSharedPlan::CreateOrUpdate +import User +import Role + +class Org + + class CreateSharedPlanService + + class << self + + def call(org = nil, threads: 0) + orgs = org.nil? ? Org.all : [org] + + Parallel.each(orgs, in_threads: threads) do |org| + OrgDateRangeable.split_months_from_creation(org) do |start_date, end_date| + StatSharedPlan::CreateOrUpdate.do( + start_date: start_date, + end_date: end_date, + org: org + ) + end + end + end + + end + + end + +end diff --git a/app/services/org/monthly_usage_service.rb b/app/services/org/monthly_usage_service.rb new file mode 100644 index 0000000..9318c61 --- /dev/null +++ b/app/services/org/monthly_usage_service.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +class Org + + class MonthlyUsageService + + class << self + + def call(current_user) + total = build_from_joined_user(current_user) + build_from_created_plan(current_user, total) + build_from_shared_plan(current_user, total) + build_from_exported_plan(current_user, total) + total.values + end + + private + + def build_model(month:, new_plans: 0, new_users: 0, downloads: 0, plans_shared: 0) + { + month: month, + new_plans: new_plans, + new_users: new_users, + downloads: downloads, + plans_shared: plans_shared + } + end + + def reducer_body(acc, rec, key_target) + month = rec.date.strftime("%b-%y") + count = rec.count + + if acc[month].present? + acc[month][key_target] = count + else + args = { month: month } + args[key_target] = count + acc[month] = build_model(args) + end + + acc + end + + def build_from_joined_user(current_user, total = {}) + joined_users = Stat::StatJoinedUser.monthly_range(org: current_user.org).order(:date) + joined_users.reduce(total) do |acc, rec| + reducer_body(acc, rec, :new_users) + end + end + + def build_from_created_plan(current_user, total = {}) + created_plans = Stat::StatCreatedPlan.monthly_range(org: current_user.org).order(:date) + created_plans.reduce(total) do |acc, rec| + reducer_body(acc, rec, :new_plans) + end + end + + def build_from_shared_plan(current_user, total = {}) + shared_plans = Stat::StatSharedPlan.monthly_range(org: current_user.org).order(:date) + shared_plans.reduce(total) do |acc, rec| + reducer_body(acc, rec, :plans_shared) + end + end + + def build_from_exported_plan(current_user, total = {}) + exported_plans = Stat::StatExportedPlan.monthly_range(org: current_user.org).order(:date) + exported_plans.reduce(total) do |acc, rec| + reducer_body(acc, rec, :downloads) + end + end + + end + + end + +end diff --git a/app/views/answers/_locking.html.erb b/app/views/answers/_locking.html.erb index 5018ff9..ed8ee29 100644 --- a/app/views/answers/_locking.html.erb +++ b/app/views/answers/_locking.html.erb @@ -1,6 +1,9 @@ +
<%= _('The following answer cannot be saved') %>
<%# We do not need to re-show example answers in this lock conflict section so leave template nil %> <%= render partial: '/answers/new_edit', locals: { template: nil, question: question, answer: answer, readonly: true, locking: true } %><%= _('since %{name} saved the answer below while you were editing. Please, combine your changes and then save the answer again.') % { name: user.name} %>
-<%= _('The following answer cannot be saved') %>
<%# We do not need to re-show example answers in this lock conflict section so leave template nil %> <%= render partial: '/answers/new_edit', locals: { template: nil, question: question, answer: answer, research_output: research_output, readonly: true, locking: true } %><%= _('since %{name} saved the answer below while you were editing. Please, combine your changes and then save the answer again.') % { name: user.name} %>
-
- <%= sanitize _('%{application_name} is provided by the %{organisation_name}.
You can find out more about us on our website (new window)%{open_in_new_window_text}. If you would like to contact us about %{application_name}, please fill out the form below.') % {
+ <%= sanitize d_('dmpopidor', '%{application_name} is provided by the %{organisation_name}.
You can find out more about us on our website. If you would like to contact us about %{application_name}, please fill out the form below.') % {
organisation_name: Rails.configuration.branding[:organisation][:name],
organisation_url: Rails.configuration.branding[:organisation][:url],
- application_name: Rails.configuration.branding[:application][:name]},
+ application_name: Rails.configuration.branding[:application][:name],
open_in_new_window_text: _('Opens in new window') },
tags: %w( a br span em ) %>
<%= _("You will need to create an account in order to accept your invitation to view the data management plan (DMP).") %>
+- <%= _('Hello %{user_name}') %{ :user_name => user_name } %> -
-- <%= d_('dmpopidor', '%{sender_name} has invited you to contribute to their Data Management Plan in %{tool_name}') %{ - :sender_name => sender_name, - :tool_name => tool_name - }%> -
-- <%= sanitize(_('%{click_here} to accept the invitation, (or copy %{link} into your browser). If you don\'t want to accept the invitation, please ignore this email.') % { - click_here: link_to(_('Click here'), link), link: link - }) %> -
-
- <%= _('All the best') %>
-
- <%= _('The %{tool_name} team') %{:tool_name => tool_name} %>
-
- <%= _('Please do not reply to this email.') %> - <%= sanitize(_('If you have any questions or need help, please contact us at %{helpdesk_email} or visit %{contact_us_url}') % { - helpdesk_email: mail_to(helpdesk_email, helpdesk_email, - subject: email_subject), - contact_us_url: link_to(contact_us, contact_us) - }) %> -
-<% end %> diff --git a/app/views/branded/devise/registrations/_personal_details.html.erb b/app/views/branded/devise/registrations/_personal_details.html.erb new file mode 100644 index 0000000..1fec0df --- /dev/null +++ b/app/views/branded/devise/registrations/_personal_details.html.erb @@ -0,0 +1,105 @@ +<%= form_for(resource, namespace: current_user.id, as: resource_name, url: registration_path(resource_name), html: {method: :put, id: 'personal_details_registration_form' }) do |f| %> ++ <%= sanitize _("Please note that your email address is used as your username. If you change this, remember to use your new email address on sign in.") %> +
+ +<%= _('You can edit any of the details below.') %>
+ <%= hidden_field_tag :unlink_flag, "false", id: 'unlink_flag' %> + +<%= (current_user.can_super_admin? ? _('Super Admin') : _('Organisational Admin')) %>
+<%= _('Plan') %> | <%= d_('dmpopidor', 'Template') %> | <%= _('Requestor') %> | -<%= _('Type') %> | +<%= d_('dmpopidor', 'Request Date') %> | <%= _('Actions') %> | @@ -26,8 +26,8 @@|
---|---|---|---|---|---|---|
<%= link_to notice.name, plan_path(notice) %> | <%= notice.template.title %> | -<%= notice.owner ? notice.owner.name(false) : "" %> | -<%= _('Feedback requested') %> | +<%= notice.feedback_requestor&.name(false) %> | +<%= l(notice.feedback_request_date.to_date, formats: :short) if notice.feedback_request_date %> | <%= link_to _('Complete'), feedback_complete_org_admin_plan_path(notice), 'data-toggle': 'tooltip', title: _('Notify the plan owner that I have finished providing feedback') %> |
<%= d_('dmpopidor', 'Structure') %> <%= paginable_sort_link('departments.name') %> | +<%= _('Abbreviated Name or Code') %> <%= paginable_sort_link('departments.code') %> | +<%= _('Actions') %> | +
---|---|---|
<%= department.name %> | +<%= department.code %> | +
+
+
+
+
+ |
+
<%= _('Organisation Type(s)') %> <%= paginable_sort_link('orgs.org_type') %> | <%= _('Templates') %> | <%= _('Users') %> | +<%= _('Active') %> | <%= _('Actions') %> | @@ -18,6 +20,9 @@<%= org.org_type_to_s %> | <%= org.template_count %> | <%= org.users.uniq.length %> | ++ <%= org.active? ? _('Yes') : _('No') %> + |
+ <% end %> <% end %> diff --git a/app/views/branded/phases/_edit_plan_answers_research_outputs.html.erb b/app/views/branded/phases/_edit_plan_answers_research_outputs.html.erb index e1a4006..4ee49fd 100644 --- a/app/views/branded/phases/_edit_plan_answers_research_outputs.html.erb +++ b/app/views/branded/phases/_edit_plan_answers_research_outputs.html.erb @@ -56,7 +56,10 @@ <% section_has_common_answers = plan.research_outputs.first.has_common_answers?(section.id) %> <% plan.research_outputs.each_with_index do |research_output, i| %> " <%= 'checked=""' if section_has_common_answers %>> <%= d_('dmpopidor', 'This section\'s answers are common to all research outputs') %> + <% end %> <% section.questions.each_with_index do |question, i| %> diff --git a/app/views/branded/phases/_overview.html.erb b/app/views/branded/phases/_overview.html.erb new file mode 100644 index 0000000..f1de96f --- /dev/null +++ b/app/views/branded/phases/_overview.html.erb @@ -0,0 +1,33 @@ +<%# locals: { phase } %> +
+
diff --git a/app/views/branded/plans/_download_form.html.erb b/app/views/branded/plans/_download_form.html.erb
index 57d9a82..18edf62 100644
--- a/app/views/branded/plans/_download_form.html.erb
+++ b/app/views/branded/plans/_download_form.html.erb
@@ -22,14 +22,20 @@
+
+
+ + <%= _('Instructions') %> + <%= _('Write plan') %> + ++
+ <%= sanitize(phase.description) %>
+
+
+
+
+
|
---|