diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..deb89fa7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,30 @@ + +# Ignore bundler config. +/.bundle + +# Ignore the default SQLite database. +/db/*.sqlite3 +/db/*.sqlite3-journal + +# Ignore all logfiles and tempfiles. +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep + +# Ignore Byebug command history file. +.byebug_history + + +#Ignore IDE +/.idea + +node_modules/ + +public/vite-dev/ +public/assets/ + +CoMapEd_development +comaped.sql + +.git/ \ No newline at end of file diff --git a/.gitignore b/.gitignore index e913eaaf..65f79e75 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,8 @@ #Ignore IDE /.idea + +node_modules + +public/vite-dev +/config/master.key diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 00000000..32419a31 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,7 @@ +stages: + - yarn_audit + +yarn_audit: + stage: yarn_audit + script: + - yarn audit diff --git a/.gitlab-ci.yml.disabled b/.gitlab-ci.yml.disabled new file mode 100644 index 00000000..32419a31 --- /dev/null +++ b/.gitlab-ci.yml.disabled @@ -0,0 +1,7 @@ +stages: + - yarn_audit + +yarn_audit: + stage: yarn_audit + script: + - yarn audit diff --git a/.msmtprc b/.msmtprc new file mode 100644 index 00000000..0ef4778f --- /dev/null +++ b/.msmtprc @@ -0,0 +1,10 @@ +defaults +port 25 + +account levumi +from noreply@comaped.de +host 172.17.0.1 +port 25 +auth off + +account default : levumi diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 00000000..def7c00b --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,8 @@ +module.exports = { + "singleQuote": true, + "tabWidth": 2, + "printWidth": 100, + "semi": false, + "arrowParens": "avoid", + "vueIndentScriptAndStyle": true +} diff --git a/CHECKS.disabled b/CHECKS.disabled new file mode 100644 index 00000000..98bcf76f --- /dev/null +++ b/CHECKS.disabled @@ -0,0 +1,3 @@ +WAIT=10 +ATTEMPTS=6 +/check.txt simple_check diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..b58a619c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +FROM ruby:3.1.2-slim + +RUN apt-get update && apt-get install -y curl + +RUN curl -sL https://deb.nodesource.com/setup_14.x | bash - && apt-get install -y nodejs + +RUN apt-get update -qq && apt-get install -yq --no-install-recommends \ + build-essential \ + gnupg2 \ + git \ + libpq-dev \ + default-libmysqlclient-dev \ + && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + + + +ENV LANG=C.UTF-8 \ + BUNDLE_JOBS=4 \ + BUNDLE_RETRY=3 \ + RAILS_ENV=development + +WORKDIR /usr/src/app +COPY Gemfile /Gemfile +COPY Gemfile.lock /Gemfile.lock + +RUN gem update --system && gem install bundler && bundle install + +COPY entrypoint.sh /usr/bin/ +RUN chmod +x /usr/bin/entrypoint.sh +ENTRYPOINT ["sh", "/usr/bin/entrypoint.sh"] + +EXPOSE 3000 + +CMD ["bundle", "exec", "rails", "s", "-b", "0.0.0.0"] diff --git a/Dockerfile.prod b/Dockerfile.prod new file mode 100644 index 00000000..e71e479a --- /dev/null +++ b/Dockerfile.prod @@ -0,0 +1,42 @@ +FROM ruby:3.1.2-slim + +RUN apt-get update && apt-get install -y curl + +RUN curl -sL https://deb.nodesource.com/setup_14.x | bash - && apt-get install -y nodejs + +RUN echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list && \ + curl -sL https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - && \ + apt-get update -qq && apt-get install -yq --no-install-recommends \ + build-essential \ + gnupg2 \ + git \ + nano \ + default-libmysqlclient-dev \ + msmtp-mta \ + && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + +ENV LANG=C.UTF-8 \ + BUNDLE_JOBS=4 \ + BUNDLE_RETRY=3 \ + RAILS_ENV=production \ + RAILS_RELATIVE_URL_ROOT='/' \ + RAILS_SERVE_STATIC_FILES=true \ + VITE_RUBY_BASE='/' \ + MYSQL_DATABASE_SCHEME=mysql2 + +WORKDIR /usr/src/app +COPY Gemfile /Gemfile +COPY Gemfile.lock /Gemfile.lock + +COPY . /usr/src/app/ +COPY .msmtprc /root/ + +RUN gem update --system && gem install bundler && bundle config set --local without test development && bundle install + +COPY entrypoint.sh /usr/bin/ +RUN chmod +x /usr/bin/entrypoint.sh +ENTRYPOINT ["sh", "/usr/bin/entrypoint.sh"] + +EXPOSE 3000 + +CMD ["bundle", "exec", "rails", "s", "-b", "0.0.0.0"] diff --git a/Dockerfile.stage b/Dockerfile.stage new file mode 100644 index 00000000..2d03a0b9 --- /dev/null +++ b/Dockerfile.stage @@ -0,0 +1,43 @@ +FROM ruby:3.1.2-slim + +RUN apt-get update && apt-get install -y curl + + + +RUN curl -sL https://deb.nodesource.com/setup_14.x | bash - && apt-get install -y nodejs + +RUN echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list && \ + curl -sL https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - && \ + apt-get update -qq && apt-get install -yq --no-install-recommends \ + build-essential \ + gnupg2 \ + git \ + sqlite3 \ + libsqlite3-dev \ + default-libmysqlclient-dev \ + nano \ + && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + +ENV LANG=C.UTF-8 \ + BUNDLE_JOBS=4 \ + BUNDLE_RETRY=3 \ + RAILS_ENV=staging \ + RAILS_RELATIVE_URL_ROOT='/comaped' \ + RAILS_SERVE_STATIC_FILES=true \ + VITE_RUBY_BASE='/comaped' + +WORKDIR /usr/src/app +COPY Gemfile /Gemfile +COPY Gemfile.lock /Gemfile.lock + +COPY . /usr/src/app/ + +RUN gem update --system && gem install bundler && bundle install + +COPY entrypoint.sh /usr/bin/ +RUN chmod +x /usr/bin/entrypoint.sh +ENTRYPOINT ["sh", "/usr/bin/entrypoint.sh"] + +EXPOSE 3000 + +CMD ["bundle", "exec", "rails", "s", "-b", "0.0.0.0"] diff --git a/Gemfile b/Gemfile index 3fa4d348..7c2e34d6 100644 --- a/Gemfile +++ b/Gemfile @@ -1,5 +1,5 @@ source 'https://rubygems.org' - +ruby "3.1.2" git_source(:github) do |repo_name| repo_name = "#{repo_name}/#{repo_name}" unless repo_name.include?("/") "https://github.com/#{repo_name}.git" @@ -7,34 +7,17 @@ end # Bundle edge Rails instead: gem 'rails', github: 'rails/rails' -gem 'rails', '~> 5.0.1' -# Use sqlite3 as the database for Active Record -gem 'sqlite3', '~> 1.3', '< 1.4' +gem 'rails', '~> 7.0.2' + # Use Puma as the app server gem 'puma', '~> 3.0' -# Use SCSS for stylesheets -gem 'sass-rails', '~> 5.0' -# Use Uglifier as compressor for JavaScript assets -gem 'uglifier', '>= 1.3.0' -# Use CoffeeScript for .coffee assets and views -gem 'coffee-rails', '~> 4.2' -# See https://github.com/rails/execjs#readme for more supported runtimes -# gem 'therubyracer', platforms: :ruby - -# Use jquery as the JavaScript library -gem 'jquery-rails' -# Turbolinks makes navigating your web application faster. Read more: https://github.com/turbolinks/turbolinks -gem 'turbolinks', '~> 5' -# Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder -gem 'jbuilder', '~> 2.5' -# Use Redis adapter to run Action Cable in production -# gem 'redis', '~> 3.0' + # Use ActiveModel has_secure_password - gem 'bcrypt', '~> 3.1.7' +gem 'bcrypt', '~> 3.1.7' + +gem 'jbuilder' # Use Capistrano for deployment -gem 'capistrano-rails' -gem 'capistrano-passenger' group :development, :test do # Call 'byebug' anywhere in the code to stop execution and get a debugger console @@ -45,19 +28,20 @@ group :development do # Access an IRB console on exception pages or by using <%= console %> anywhere in the code. gem 'web-console', '>= 3.3.0' gem 'listen', '~> 3.0.5' - # Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring - gem 'spring' - gem 'spring-watcher-listen', '~> 2.0.0' -end -group :production do - gem 'mysql2' end +gem 'mysql2' + # Windows does not include zoneinfo files, so bundle the tzinfo-data gem gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby] #Additional gems: gem 'rubyzip' -gem 'bootstrap-sass', '~> 3.3.7' -gem 'visjs-rails', '~> 4.21.0.0' \ No newline at end of file +gem "js-routes" + +gem 'vite_rails' + +gem "sprockets-rails", "~> 3.4" + +gem "redis", "~> 5.0" diff --git a/Gemfile.lock b/Gemfile.lock index 6c62fc67..5a967000 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,215 +1,205 @@ GEM remote: https://rubygems.org/ specs: - actioncable (5.0.7.1) - actionpack (= 5.0.7.1) - nio4r (>= 1.2, < 3.0) - websocket-driver (~> 0.6.1) - actionmailer (5.0.7.1) - actionpack (= 5.0.7.1) - actionview (= 5.0.7.1) - activejob (= 5.0.7.1) + actioncable (7.0.4) + actionpack (= 7.0.4) + activesupport (= 7.0.4) + nio4r (~> 2.0) + websocket-driver (>= 0.6.1) + actionmailbox (7.0.4) + actionpack (= 7.0.4) + activejob (= 7.0.4) + activerecord (= 7.0.4) + activestorage (= 7.0.4) + activesupport (= 7.0.4) + mail (>= 2.7.1) + net-imap + net-pop + net-smtp + actionmailer (7.0.4) + actionpack (= 7.0.4) + actionview (= 7.0.4) + activejob (= 7.0.4) + activesupport (= 7.0.4) mail (~> 2.5, >= 2.5.4) + net-imap + net-pop + net-smtp rails-dom-testing (~> 2.0) - actionpack (5.0.7.1) - actionview (= 5.0.7.1) - activesupport (= 5.0.7.1) - rack (~> 2.0) - rack-test (~> 0.6.3) + actionpack (7.0.4) + actionview (= 7.0.4) + activesupport (= 7.0.4) + rack (~> 2.0, >= 2.2.0) + rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.0, >= 1.0.2) - actionview (5.0.7.1) - activesupport (= 5.0.7.1) + rails-html-sanitizer (~> 1.0, >= 1.2.0) + actiontext (7.0.4) + actionpack (= 7.0.4) + activerecord (= 7.0.4) + activestorage (= 7.0.4) + activesupport (= 7.0.4) + globalid (>= 0.6.0) + nokogiri (>= 1.8.5) + actionview (7.0.4) + activesupport (= 7.0.4) builder (~> 3.1) - erubis (~> 2.7.0) + erubi (~> 1.4) rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.0, >= 1.0.3) - activejob (5.0.7.1) - activesupport (= 5.0.7.1) + rails-html-sanitizer (~> 1.1, >= 1.2.0) + activejob (7.0.4) + activesupport (= 7.0.4) globalid (>= 0.3.6) - activemodel (5.0.7.1) - activesupport (= 5.0.7.1) - activerecord (5.0.7.1) - activemodel (= 5.0.7.1) - activesupport (= 5.0.7.1) - arel (~> 7.0) - activesupport (5.0.7.1) + activemodel (7.0.4) + activesupport (= 7.0.4) + activerecord (7.0.4) + activemodel (= 7.0.4) + activesupport (= 7.0.4) + activestorage (7.0.4) + actionpack (= 7.0.4) + activejob (= 7.0.4) + activerecord (= 7.0.4) + activesupport (= 7.0.4) + marcel (~> 1.0) + mini_mime (>= 1.1.0) + activesupport (7.0.4) concurrent-ruby (~> 1.0, >= 1.0.2) - i18n (>= 0.7, < 2) - minitest (~> 5.1) - tzinfo (~> 1.1) - airbrussh (1.3.1) - sshkit (>= 1.6.1, != 1.7.0) - arel (7.1.4) - autoprefixer-rails (9.4.7) - execjs - bcrypt (3.1.12) - bindex (0.5.0) - bootstrap-sass (3.3.7) - autoprefixer-rails (>= 5.2.1) - sass (>= 3.3.4) - builder (3.2.3) - byebug (11.0.0) - capistrano (3.11.0) - airbrussh (>= 1.0.0) - i18n - rake (>= 10.0.0) - sshkit (>= 1.9.0) - capistrano-bundler (1.5.0) - capistrano (~> 3.1) - capistrano-passenger (0.2.0) - capistrano (~> 3.0) - capistrano-rails (1.4.0) - capistrano (~> 3.1) - capistrano-bundler (~> 1.1) - coffee-rails (4.2.2) - coffee-script (>= 2.2.0) - railties (>= 4.0.0) - coffee-script (2.4.1) - coffee-script-source - execjs - coffee-script-source (1.12.2) - concurrent-ruby (1.1.4) - crass (1.0.4) - erubis (2.7.0) - execjs (2.7.0) - ffi (1.10.0) - globalid (0.4.2) - activesupport (>= 4.2.0) - i18n (1.5.3) + i18n (>= 1.6, < 2) + minitest (>= 5.1) + tzinfo (~> 2.0) + bcrypt (3.1.18) + bindex (0.8.1) + builder (3.2.4) + byebug (11.1.3) + concurrent-ruby (1.1.10) + connection_pool (2.3.0) + crass (1.0.6) + dry-cli (0.7.0) + erubi (1.11.0) + ffi (1.15.5) + globalid (1.0.0) + activesupport (>= 5.0) + i18n (1.12.0) concurrent-ruby (~> 1.0) - jbuilder (2.8.0) - activesupport (>= 4.2.0) - multi_json (>= 1.2) - jquery-rails (4.3.3) - rails-dom-testing (>= 1, < 3) - railties (>= 4.2.0) - thor (>= 0.14, < 2.0) + jbuilder (2.11.5) + actionview (>= 5.0.0) + activesupport (>= 5.0.0) + js-routes (2.2.4) + railties (>= 4) listen (3.0.8) rb-fsevent (~> 0.9, >= 0.9.4) rb-inotify (~> 0.9, >= 0.9.7) - loofah (2.2.3) + loofah (2.19.0) crass (~> 1.0.2) nokogiri (>= 1.5.9) mail (2.7.1) mini_mime (>= 0.1.1) - method_source (0.9.2) - mini_mime (1.0.1) - mini_portile2 (2.4.0) - minitest (5.11.3) - multi_json (1.13.1) - mysql2 (0.5.2) - net-scp (1.2.1) - net-ssh (>= 2.6.5) - net-ssh (5.1.0) - nio4r (2.3.1) - nokogiri (1.10.1) - mini_portile2 (~> 2.4.0) - puma (3.12.0) - rack (2.0.6) - rack-test (0.6.3) - rack (>= 1.0) - rails (5.0.7.1) - actioncable (= 5.0.7.1) - actionmailer (= 5.0.7.1) - actionpack (= 5.0.7.1) - actionview (= 5.0.7.1) - activejob (= 5.0.7.1) - activemodel (= 5.0.7.1) - activerecord (= 5.0.7.1) - activesupport (= 5.0.7.1) - bundler (>= 1.3.0) - railties (= 5.0.7.1) - sprockets-rails (>= 2.0.0) + marcel (1.0.2) + method_source (1.0.0) + mini_mime (1.1.2) + minitest (5.16.3) + mysql2 (0.5.4) + net-imap (0.3.1) + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.1.3) + timeout + net-smtp (0.3.2) + net-protocol + nio4r (2.5.8) + nokogiri (1.13.8-x86_64-linux) + racc (~> 1.4) + puma (3.12.6) + racc (1.6.0) + rack (2.2.4) + rack-proxy (0.7.4) + rack + rack-test (2.0.2) + rack (>= 1.3) + rails (7.0.4) + actioncable (= 7.0.4) + actionmailbox (= 7.0.4) + actionmailer (= 7.0.4) + actionpack (= 7.0.4) + actiontext (= 7.0.4) + actionview (= 7.0.4) + activejob (= 7.0.4) + activemodel (= 7.0.4) + activerecord (= 7.0.4) + activestorage (= 7.0.4) + activesupport (= 7.0.4) + bundler (>= 1.15.0) + railties (= 7.0.4) rails-dom-testing (2.0.3) activesupport (>= 4.2.0) nokogiri (>= 1.6) - rails-html-sanitizer (1.0.4) - loofah (~> 2.2, >= 2.2.2) - railties (5.0.7.1) - actionpack (= 5.0.7.1) - activesupport (= 5.0.7.1) + rails-html-sanitizer (1.4.3) + loofah (~> 2.3) + railties (7.0.4) + actionpack (= 7.0.4) + activesupport (= 7.0.4) method_source - rake (>= 0.8.7) - thor (>= 0.18.1, < 2.0) - rake (12.3.2) - rb-fsevent (0.10.3) - rb-inotify (0.10.0) + rake (>= 12.2) + thor (~> 1.0) + zeitwerk (~> 2.5) + rake (13.0.6) + rb-fsevent (0.11.2) + rb-inotify (0.10.1) ffi (~> 1.0) - rubyzip (1.2.2) - sass (3.7.3) - sass-listen (~> 4.0.0) - sass-listen (4.0.0) - rb-fsevent (~> 0.9, >= 0.9.4) - rb-inotify (~> 0.9, >= 0.9.7) - sass-rails (5.0.7) - railties (>= 4.0.0, < 6) - sass (~> 3.1) - sprockets (>= 2.8, < 4.0) - sprockets-rails (>= 2.0, < 4.0) - tilt (>= 1.1, < 3) - spring (2.0.2) - activesupport (>= 4.2) - spring-watcher-listen (2.0.1) - listen (>= 2.7, < 4.0) - spring (>= 1.2, < 3.0) - sprockets (3.7.2) + redis (5.0.5) + redis-client (>= 0.9.0) + redis-client (0.10.0) + connection_pool + rubyzip (2.3.2) + sprockets (4.1.1) concurrent-ruby (~> 1.0) rack (> 1, < 3) - sprockets-rails (3.2.1) - actionpack (>= 4.0) - activesupport (>= 4.0) + sprockets-rails (3.4.2) + actionpack (>= 5.2) + activesupport (>= 5.2) sprockets (>= 3.0.0) - sqlite3 (1.3.13) - sshkit (1.18.2) - net-scp (>= 1.1.2) - net-ssh (>= 2.8.0) - thor (0.20.3) - thread_safe (0.3.6) - tilt (2.0.9) - turbolinks (5.2.0) - turbolinks-source (~> 5.2) - turbolinks-source (5.2.0) - tzinfo (1.2.5) - thread_safe (~> 0.1) - uglifier (4.1.20) - execjs (>= 0.3.0, < 3) - visjs-rails (4.21.0.0) - web-console (3.7.0) - actionview (>= 5.0) - activemodel (>= 5.0) + thor (1.2.1) + timeout (0.3.0) + tzinfo (2.0.5) + concurrent-ruby (~> 1.0) + vite_rails (3.0.12) + railties (>= 5.1, < 8) + vite_ruby (~> 3.0, >= 3.2.2) + vite_ruby (3.2.6) + dry-cli (~> 0.7.0) + rack-proxy (~> 0.6, >= 0.6.1) + zeitwerk (~> 2.2) + web-console (4.2.0) + actionview (>= 6.0.0) + activemodel (>= 6.0.0) bindex (>= 0.4.0) - railties (>= 5.0) - websocket-driver (0.6.5) + railties (>= 6.0.0) + websocket-driver (0.7.5) websocket-extensions (>= 0.1.0) - websocket-extensions (0.1.3) + websocket-extensions (0.1.5) + zeitwerk (2.6.1) PLATFORMS - ruby + x86_64-linux DEPENDENCIES bcrypt (~> 3.1.7) - bootstrap-sass (~> 3.3.7) byebug - capistrano-passenger - capistrano-rails - coffee-rails (~> 4.2) - jbuilder (~> 2.5) - jquery-rails + jbuilder + js-routes listen (~> 3.0.5) mysql2 puma (~> 3.0) - rails (~> 5.0.1) + rails (~> 7.0.2) + redis (~> 5.0) rubyzip - sass-rails (~> 5.0) - spring - spring-watcher-listen (~> 2.0.0) - sqlite3 (~> 1.3, < 1.4) - turbolinks (~> 5) + sprockets-rails (~> 3.4) tzinfo-data - uglifier (>= 1.3.0) - visjs-rails (~> 4.21.0.0) + vite_rails web-console (>= 3.3.0) +RUBY VERSION + ruby 3.1.2p20 + BUNDLED WITH - 2.0.1 + 2.3.18 diff --git a/app.json b/app.json new file mode 100644 index 00000000..e8810772 --- /dev/null +++ b/app.json @@ -0,0 +1,8 @@ +{ + "name": "comaped", + "scripts": { + "dokku": { + "predeploy": "bundle exec rake assets:precompile" + } + } +} diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js index b16e53d6..1b3b2b8f 100644 --- a/app/assets/config/manifest.js +++ b/app/assets/config/manifest.js @@ -1,3 +1,2 @@ //= link_tree ../images -//= link_directory ../javascripts .js -//= link_directory ../stylesheets .css +//= link_tree ../../javascript .js diff --git a/app/assets/images/example_map_de.png b/app/assets/images/example_map_de.png index bed5be83..ce3121bf 100644 Binary files a/app/assets/images/example_map_de.png and b/app/assets/images/example_map_de.png differ diff --git a/app/assets/images/example_map_de_old.png b/app/assets/images/example_map_de_old.png new file mode 100644 index 00000000..bed5be83 Binary files /dev/null and b/app/assets/images/example_map_de_old.png differ diff --git a/app/assets/images/example_map_en.png b/app/assets/images/example_map_en.png index 06daae22..7d9b8a9d 100644 Binary files a/app/assets/images/example_map_en.png and b/app/assets/images/example_map_en.png differ diff --git a/app/assets/images/example_map_en_old.png b/app/assets/images/example_map_en_old.png new file mode 100644 index 00000000..06daae22 Binary files /dev/null and b/app/assets/images/example_map_en_old.png differ diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js.bak similarity index 92% rename from app/assets/javascripts/application.js rename to app/assets/javascripts/application.js.bak index e039c882..f7e2ee4e 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js.bak @@ -10,9 +10,6 @@ // Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details // about supported directives. // -//= require jquery -//= require jquery_ujs //= require turbolinks -//= require vis //= require bootstrap-sprockets //= require_tree . diff --git a/app/assets/stylesheets/application.css.sass b/app/assets/stylesheets/application.scss_old similarity index 78% rename from app/assets/stylesheets/application.css.sass rename to app/assets/stylesheets/application.scss_old index 86105ed8..19a0d2bb 100644 --- a/app/assets/stylesheets/application.css.sass +++ b/app/assets/stylesheets/application.scss_old @@ -10,10 +10,13 @@ * files in this directory. Styles in this file should be added after the last require_* statement. * It is generally better to create a new file per style scope. * - *= require_tree . - *= require_self */ -@import "bootstrap-sprockets" -@import "bootstrap" -@import "vis" +@import "bootstrap"; +@import "frontend_editor"; +@import "bootstrap-overrides"; + +@import "bootstrap-icons-1.8.3/bootstrap-icons.css"; +@import "vis-network.min.css"; +@import "vis-timeline.min.css"; + diff --git a/app/assets/stylesheets/bootstrap-overrides.scss_old b/app/assets/stylesheets/bootstrap-overrides.scss_old new file mode 100644 index 00000000..ab605e2b --- /dev/null +++ b/app/assets/stylesheets/bootstrap-overrides.scss_old @@ -0,0 +1,30 @@ +.dropdown-toggle { + &:after { + display: none; + } +} + +.dropdown-menu, .modal-dialog { + box-shadow: 0 0.2em 0.8em rgba(0, 0, 0, 0.175) +} + +.popover-header { + display: flex; + justify-content: space-between; + .close { + span:before { + font-size: 1em !important; + } + } +} + +#menuButton span:before { + font-weight: bold !important; +} + +.card-header { + a { + color: black; + text-decoration: none; + } +} \ No newline at end of file diff --git a/app/assets/stylesheets/frontend_editor.scss b/app/assets/stylesheets/frontend_editor.scss deleted file mode 100644 index 44d201a7..00000000 --- a/app/assets/stylesheets/frontend_editor.scss +++ /dev/null @@ -1,178 +0,0 @@ -// Place all the styles related to the ConceptMaps controller here. -// They will automatically be included in application.css. -// You can use Sass (SCSS) here: http://sass-lang.com/ - -html, body { - height: 100%; - min-height: 100%; - margin: 0px; - padding: 0px; -} - -#login { - position: relative; - margin-top: 50px; - margin-left: 150px; - margin-right: 150px; -} - -#map-canvas { - position: fixed; - top: 0px; - left: 0px; - width: 100%; - height: 100%; - box-sizing: border-box; - background-color: #f8f8f8; -} - -#header-nav { - position: absolute; - top: 15px; - left: 30px; - z-index: 1; - display: block; -} - -#header-code { - position: absolute; - right: 50%; - top: 15px; - z-index: 1; - display: inline-block; - transform: translatex(50%); - background-color: #1b809e; - padding: 5px; - color: white; -} - -#header-search { - position: absolute; - right: 30px; - top: 15px; - z-index: 1; - display: block; -} - -#submit-mail { - background-color: #1b809e; -} - -#submit-mail:hover { - background-color: #07668a; -} - -.canvasButton { - color: #1b809e; - font-family: "Glyphicons Halflings"; - font-size: 32px; -} - -.canvasButton:hover { - color: #07668a; - cursor: pointer; -} - -#context-help { - position: absolute; - right: 50%; - bottom: 15px; - z-index: 1; - display: inline-block; - transform: translatex(50%); - background-color: #1b809e; - padding: 5px; - color: white; - padding: 10px; - box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); -} - -#task { - position: absolute; - right: 50%; - top: 75px; - z-index: 1; - display: inline-block; - transform: translatex(50%); - background-color: white; - padding: 5px; - color: black; - box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); - padding: 10px; -} - -.vis-button { - color: #1b809e; - font-family: "Glyphicons Halflings"; - font-size: 32px; - cursor: pointer; -} - -.vis-button:hover { - box-shadow: none !important; - color: #07668a; -} - -.vis-right { - background-image: none !important; - margin-left: 15px; - margin-bottom: 15px; -} - -.vis-right::after { - content: "\e131"; -} - -.vis-left { - background-image: none !important; - margin-left: 15px; - margin-bottom: 15px; -} -.vis-left::after { - content: "\e132"; -} - -.vis-up { - background-image: none !important; - margin-left: 15px; - margin-bottom: 15px; -} -.vis-up::after { - content: "\e133"; -} - -.vis-down { - background-image: none !important; - margin-left: 15px; - margin-bottom: 15px; -} -.vis-down::after { - content: "\e134"; -} - -.vis-zoomIn { - background-image: none !important; - margin-right: 15px; - margin-bottom: 15px; -} -.vis-zoomIn::after { - content: "\e081"; -} - -.vis-zoomOut { - background-image: none !important; - margin-right: 15px; - margin-bottom: 15px; -} -.vis-zoomOut::after { - content: "\e082"; -} - -.vis-zoomExtends { - background-image: none !important; - margin-right: 15px; - margin-bottom: 15px; -} -.vis-zoomExtends::after { - content: "\e087"; -} diff --git a/app/assets/stylesheets/frontend_editor.scss_old b/app/assets/stylesheets/frontend_editor.scss_old new file mode 100644 index 00000000..ae82d3ea --- /dev/null +++ b/app/assets/stylesheets/frontend_editor.scss_old @@ -0,0 +1,572 @@ +// Place all the styles related to the ConceptMaps controller here. +// They will automatically be included in application.css. +// You can use Sass (SCSS) here: http://sass-lang.com/ + +html, body { + height: 100%; + min-height: 100%; + margin: 0px; + padding: 0px; +} +/* DH: Mobile View + smartphones, iPhone, portrait 480x320 phones + portrait e-readers (Nook/Kindle), smaller tablets @ 600 or @ 640 wide. + portrait tablets, portrait iPad, landscape e-readers, landscape 800x480 or 854x480 phones + tablet, landscape iPad, lo-res laptops ands desktops +*/ +@media (min-width:320px) { + #map-canvas { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + box-sizing: border-box; + background-color: #f8f8f8; + + } + #right{ + display: flex; + flex-flow: column; + width: 100%; + background-color: #f8f8f8; + float: right; + height: 100%; + + + } + #recent_changes { + flex: 1 1 auto; + overflow-y: auto; + } + #users{ + width: 100%; + } + #user_me { + width: 100%; + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); + border: 1px solid #D7D7D7; + text-align: center; + margin-bottom: 2em; + } + #students { + margin: 0; + padding: 0; + width: 100%; + background-color: white; + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); + margin-bottom: 2em; + border: 1px solid #D7D7D7; + + } + #students_header { + width: 100%; + + background-color: #f7f7f7; + text-align: center; + } + + .log { + width: 100%; + margin-bottom: 1em; + font-size: 12px; + color: black; + + + } + .log_header { + width: 100%; + display: flex; + text-align: center; + } + .change_header { + border-top: 1px solid #D7D7D7; + border-left: 1px solid #D7D7D7; + border-right: 1px solid #D7D7D7; + background-color: #f7f7f7; + margin-left: 12px; + + text-align: center; + + } + .log_entry { + width: 100%; + background-color: white; + overflow: hidden; + border: 1px solid #ebebeb; + display: flex; + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); + } + .student { + width: 20%; + border-right: 1px dotted black; + float: left; + text-align: center; + align-items: center; + display: flex; + justify-content: center; + } + .student_name{ + top: 50%; + display: flex; + align-items: center; + } + .context { + width: 75%; + float: right; + text-align: left; + align-items: center; + display: flex; + } + .changes { + margin: 0; + } + #toggle_button{ + position: absolute; + right: 40%; + bottom: 0px; + z-index: 1; + display: block; + width: 20%; + background-color: black; + text-align: center; + color: white; + clip-path: polygon(0 0, 100% 0, 100% 50%, 50% 100%, 50% 100%, 0% 50%); + font-size: 14px; + } + + #header-nav { + position: absolute; + top: 15px; + left: 30px; + z-index: 1; + display: block; + } + + #header-code { + position: absolute; + right: 50%; + top: 15px; + z-index: 1; + display: inline-block; + transform: translatex(50%); + background-color: #1b809e; + padding: 5px; + color: white; + } + + #header-search { + position: absolute; + right: 30px; + top: 15px; + z-index: 1; + display: block; + } + + #submit-mail { + background-color: #1b809e; + &:hover { + background-color: #07668a; + } + } + + .canvasButton { + color: #1b809e; + font-family: "bootstrap-icons"; + font-size: 32px; + &:hover { + color: #07668a; + cursor: pointer; + } + } + + #context-help { + position: absolute; + right: 50%; + bottom: 15px; + z-index: 1; + display: inline-block; + transform: translatex(50%); + background-color: #1b809e; + padding: 5px; + color: white; + padding: 10px; + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); + } + + #task { + position: absolute; + right: 50%; + top: 75px; + z-index: 1; + display: inline-block; + transform: translatex(50%); + background-color: white; + padding: 5px; + color: black; + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); + padding: 10px; + } + + .vis-button { + color: #1b809e; + font-family: "bootstrap-icons"; + font-size: 32px; + cursor: pointer; + &:hover { + box-shadow: none !important; + color: #07668a; + } + } + + .vis-right { + background-image: none !important; + margin-left: 15px; + margin-bottom: 15px; + &:after { + content: "\f133"; + } + } + + .vis-left { + background-image: none !important; + margin-left: 15px; + margin-bottom: 15px; + &:after { + content: "\f129"; + } + } + + .vis-up { + background-image: none !important; + margin-left: 15px; + margin-bottom: 15px; + &:after { + content: "\f139"; + } + } + + .vis-down { + background-image: none !important; + margin-left: 15px; + margin-bottom: 15px; + &:after { + content: "\f118"; + } + } + + .vis-zoomIn { + background-image: none !important; + margin-right: 15px; + margin-bottom: 15px; + &:after { + content: "\f4f9"; + } + } + + .vis-zoomOut { + background-image: none !important; + margin-right: 15px; + margin-bottom: 15px; + &:after { + content: "\f2e5"; + } + } + + .vis-zoomExtends { + background-image: none !important; + margin-right: 15px; + margin-bottom: 15px; + &:after { + content: "\f2dd"; + } + } + + +} + +// DH: big landscape tablets, laptops, and desktops +@media (min-width:1025px) { + #toggle_button{ + display: none; + } + + #map-canvas { + position: fixed; + top: 0; + left: 0; + width: 80%; + height: 100%; + box-sizing: border-box; + background-color: #f8f8f8; + } + #right{ + display: flex; + flex-flow: column; + width: 20%; + background-color: #f8f8f8; + float: right; + height: 100%; + + + } + #recent_changes { + flex: 1 1 auto; + overflow-y: auto; + } + #users{ + width: 100%; + } + #user_me { + width: 100%; + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); + border: 1px solid #D7D7D7; + text-align: center; + margin-bottom: 2em; + } + #students { + margin: 0; + padding: 0; + width: 100%; + background-color: white; + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); + margin-bottom: 2em; + border: 1px solid #D7D7D7; + font-size: 12px; + + } + #students_header { + width: 100%; + + background-color: #f7f7f7; + text-align: center; + } + + .log { + width: 100%; + margin-bottom: 1em; + font-size: 12px; + color: black; + + + } + #log_header { + width: 100%; + display: flex; + text-align: center; + } + .change_header { + border-top: 1px solid #D7D7D7; + border-left: 1px solid #D7D7D7; + border-right: 1px solid #D7D7D7; + background-color: #f7f7f7; + margin-left: 12px; + width: 100%; + + + } + .log_entry { + width: 100%; + background-color: white; + overflow: hidden; + border: 1px solid #ebebeb; + display: flex; + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); + } + .student { + width: 35%; + border-right: 1px dotted black; + float: left; + text-align: center; + align-items: center; + display: flex; + justify-content: center; + } + .student_name{ + top: 50%; + display: flex; + align-items: center; + } + .context { + width: 65%; + float: right; + text-align: left; + align-items: center; + display: flex; + } + .changes { + margin: 0; + } + + #header-nav { + position: absolute; + top: 15px; + left: 30px; + z-index: 1; + display: block; + } + + #header-code { + position: absolute; + right: 50%; + top: 15px; + z-index: 1; + display: inline-block; + transform: translatex(50%); + background-color: #1b809e; + padding: 5px; + color: white; + } + + #header-search { + position: absolute; + // DH + //right: 30px; + right: 21%; + top: 15px; + z-index: 1; + display: block; + } + + #submit-mail { + background-color: #1b809e; + &:hover { + background-color: #07668a; + } + } + + .canvasButton { + color: #1b809e; + font-family: "bootstrap-icons"; + font-size: 32px; + &:hover { + color: #07668a; + cursor: pointer; + } + } + + #context-help { + position: absolute; + right: 50%; + bottom: 15px; + z-index: 1; + display: inline-block; + transform: translatex(50%); + background-color: #1b809e; + padding: 5px; + color: white; + padding: 10px; + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); + } + + #task { + position: absolute; + right: 50%; + top: 75px; + z-index: 1; + display: inline-block; + transform: translatex(50%); + background-color: white; + padding: 5px; + color: black; + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); + padding: 10px; + } + + .vis-button { + color: #1b809e; + font-family: "bootstrap-icons"; + font-size: 32px; + cursor: pointer; + &:hover { + box-shadow: none !important; + color: #07668a; + } + } + + .vis-right { + background-image: none !important; + margin-left: 15px; + margin-bottom: 15px; + &:after { + content: "\f133"; + } + } + + .vis-left { + background-image: none !important; + margin-left: 15px; + margin-bottom: 15px; + &:after { + content: "\f129"; + } + } + + .vis-up { + background-image: none !important; + margin-left: 15px; + margin-bottom: 15px; + &:after { + content: "\f139"; + } + } + + .vis-down { + background-image: none !important; + margin-left: 15px; + margin-bottom: 15px; + &:after { + content: "\f118"; + } + } + + .vis-zoomIn { + background-image: none !important; + margin-right: 15px; + margin-bottom: 15px; + &:after { + content: "\f4f9"; + } + } + + .vis-zoomOut { + background-image: none !important; + margin-right: 15px; + margin-bottom: 15px; + &:after { + content: "\f2e5"; + } + } + + .vis-zoomExtends { + background-image: none !important; + margin-right: 15px; + margin-bottom: 15px; + &:after { + content: "\f2dd"; + } + } +} + +// DH hi-res laptops and desktops +@media (min-width:1281px) { + #students{ + font-size: 14px; + } + .student { + width: 25%; + border-right: 1px dotted black; + float: left; + text-align: center; + align-items: center; + display: flex; + justify-content: center; + } + .context { + width: 75%; + float: right; + text-align: left; + align-items: center; + display: flex; + } +} diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb index 0ff5442f..817b1744 100644 --- a/app/channels/application_cable/connection.rb +++ b/app/channels/application_cable/connection.rb @@ -1,4 +1,19 @@ module ApplicationCable class Connection < ActionCable::Connection::Base + # the current user identifies the Connection + identified_by :current_student + + def connect + @current_user_id = cookies[:student_id] + @map_id = cookies[:map_id] + end + + def disconnect + Student.destroy(@current_user_id) + ActionCable.server.broadcast( + 'test_channel', + { action: 'user_disconnected', user_id: @current_user_id, map_id: @map_id } + ) + end end end diff --git a/app/channels/test_channel.rb b/app/channels/test_channel.rb new file mode 100644 index 00000000..76ce5d92 --- /dev/null +++ b/app/channels/test_channel.rb @@ -0,0 +1,14 @@ +class TestChannel < ApplicationCable::Channel + def subscribed + # Start the streaming from the channel + stream_from 'test_channel' + end + + def ping(data) + ActionCable.server.broadcast('test_channel', { action: 'pong' }) + end + + def unsubscribed + # Any cleanup needed when channel is unsubscribed + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 43547a63..a99a4011 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -2,11 +2,28 @@ class ApplicationController < ActionController::Base protect_from_forgery with: :exception before_action :set_locale - before_action :check_login_frontend, except: [:frontend, :backend, :login, :map_link, :map_form, :logout] #Logout hier drin lassen? Sonst: getrennte Actions? - before_action :check_login_backend, except: [:frontend, :backend, :login, :map_link, :map_form, :logout] #Logout hier drin lassen? Sonst: getrennte Actions? + before_action :check_login_frontend, except: %i[frontend backend login map_link map_form logout] #Logout hier drin lassen? Sonst: getrennte Actions? + before_action :check_login_backend, except: %i[frontend backend login map_link map_form logout] #Logout hier drin lassen? Sonst: getrennte Actions? #GET '/' def frontend + if session.has_key?(:student_id) + # DH: Delete the student, if not already happened + if Student.find_by_id(session[:student_id]) + Student.where(id: session[:student_id]).destroy_all + end + + # DH: Inform the channel, that the student left + ActionCable.server.broadcast( + 'test_channel', + { action: 'user_left', user_id: session[:student_id], map_id: session[:map] } + ) + + #DH: Delete the session and cookie + session.delete(:student_id) + cookies.delete :student_id + cookies.delete :map_id + end render 'frontend', layout: 'login' end @@ -22,25 +39,38 @@ def login session[:user] = @user.id redirect_to user_projects_path @user else - redirect_to '/backend', notice: (I18n.t('application.backend.wrong_credentials')) + redirect_to root_path, notice: (I18n.t('application.backend.wrong_credentials')) end end #GET '/logout' def logout - puts params[:target] + # DH: Inform the channel + ActionCable.server.broadcast( + 'test_channel', + { action: 'user_left', user_id: cookies[:student_id], map_id: session[:map] } + ) + + #DH: delete the student after logout, so the names can be reused + Student.where(id: cookies[:student_id]).destroy_all + + #puts params[:target] + + #DH: Reset the session + cookies[:student_id] = nil + cookies[:map_id] = nil + session[:student_id] = nil if params[:target] == 'frontend' @map = ConceptMap.find(session[:map]) session[:map] = nil redirect_to '/', notice: (I18n.t('application.frontend.goodbye', code: @map.code)) elsif params[:target] == 'backend' session[:user] = nil - redirect_to '/backend', notice: (I18n.t('application.backend.goodbye')) + redirect_to root_path, notice: (I18n.t('application.backend.goodbye')) end end - def send_code - end + def send_code; end #GET /map/:code def map_link @@ -53,12 +83,36 @@ def map_link end end - #POST /map + ####################################################### + ## Returns or creates a map for the entered code + ## POST /map + ####################################################### def map_form code = params[:code] + + # Check if there is already a session for the student + if session.has_key?(:student_id) + #Delete the student, if not already happened + if Student.find_by_id(session[:student_id]) + Student.where(id: session[:student_id]).destroy_all + end + + # DH: Inform the channel, that the user left + ActionCable.server.broadcast( + 'test_channel', + { action: 'user_left', user_id: session[:student_id], map_id: session[:map] } + ) + + #DH: Delete the session and cookie + session.delete(:student_id) + cookies.delete :student_id + end + + # DH: Do the normal stuff unless code.nil? || code.blank? @map = ConceptMap.prepare_map(code) #Retrieve map or create a new one - unless @map.nil? #If a map has been found or created => Save it to session hash and continue, else return to welcome screen + unless @map.nil? + #If a map has been found or created => Save it to session hash and continue, else return to welcome screen session[:map] = @map.id redirect_to edit_concept_map_path @map else @@ -71,30 +125,70 @@ def map_form private + ####################################################### + ## Sets the language + ####################################################### def set_locale if request.env['HTTP_ACCEPT_LANGUAGE'].nil? I18n.locale = :en else I18n.locale = request.env['HTTP_ACCEPT_LANGUAGE'].scan(/^[a-z]{2}/).first - unless (I18n.locale == :de) || (I18n.locale == :en) - I18n.locale = :en - end + I18n.locale = :en unless (I18n.locale == :de) || (I18n.locale == :en) end end + ####################################################### + ## Handles setting of necessary variables; redirects deep links to the bot trap + ####################################################### def check_login_frontend if session.has_key?(:map) @map = ConceptMap.find(session[:map]) + elsif params.has_key?(:code) + @map = ConceptMap.find_by_code(params[:code]) + redirect_to controller: 'concept_maps', action: 'redirect_to_edit', code: params[:code] and + return else - redirect_to '/' + redirect_to '/' and return + end + + @all_students = Student.where('concept_map_id = ?', @map.id) + + set_current_student + end + + ####################################################### + ## Handles student getting/creation + ####################################################### + def set_current_student + if session[:student_id].present? + @current_student = Student.find_by_id(session[:student_id]) + return + end + + if Student.where('concept_map_id = ?', @map.id).count > 20 + redirect_to '/', notice: (I18n.t('application.frontend.full', code: @map.code)) end + + @current_student = Student.generate(@map.id) + + session[:student_id] = @current_student.id + ActionCable.server.broadcast( + 'test_channel', + { + action: 'user_joined', + user: @current_student.name, + user_color: @current_student.color, + user_id: @current_student.id, + map_id: @map.id + } + ) end def check_login_backend if session.has_key?(:user) @login = User.find(session[:user]) else - redirect_to '/backend' + redirect_to root_path end end end diff --git a/app/controllers/concept_maps_controller.rb b/app/controllers/concept_maps_controller.rb index 046e75a6..192c931c 100644 --- a/app/controllers/concept_maps_controller.rb +++ b/app/controllers/concept_maps_controller.rb @@ -1,23 +1,33 @@ class ConceptMapsController < ApplicationController - - skip_before_action :check_login_frontend, except: [:edit] - skip_before_action :check_login_backend, only: [:edit, :show] + skip_before_action :check_login_frontend, except: %i[edit update] + skip_before_action :check_login_backend, + only: %i[edit show update redirect_to_edit confirm_redirect] before_action :login_for_show, only: [:show] - before_action :set_user_project_survey, only: [:new, :create, :destroy, :index] - before_action :set_concept_map, only: [:edit, :update, :show, :destroy] + before_action :set_user_project_survey, only: %i[new create destroy index page] + before_action :set_concept_map, only: %i[update show destroy] + before_action :set_concept_map_frontend, only: %I[edit] - # GET /concept_maps/:page.js + # GET /concept_maps/ def index @page = params[:page].to_i || 0 - @maps = @survey.concept_maps.offset(@page*10).limit(10).order(updated_at: :desc) + @maps = @survey.concept_maps.offset(@page * 10).limit(10).order(updated_at: :desc) respond_to do |format| - format.js { - if @maps.size == 0 - head :ok - end - } + format.html { head :ok if @maps.size == 0 } + format.json {} + end + end + + # GET /concept_maps/ + # will render the partial without a layout; used for scroll-loading + def page + @page = params[:page].to_i || 0 + @maps = @survey.concept_maps.offset(@page * 10).limit(10).order(updated_at: :desc) + if @maps.size == 0 + head :ok + return end + render layout: false end # GET /concept_maps/1 @@ -25,112 +35,144 @@ def index # GET /concept_maps/1.json def show respond_to do |format| - format.html {render 'show', layout: 'backend'} - format.json { + format.html { render 'show', layout: 'backend' } + format.json do if params.has_key?(:versions) - send_file @concept_map.to_zip(false), filename:@concept_map.code+".zip", type: "application/zip" + send_file @concept_map.to_zip(false), + filename: @concept_map.code + '.zip', + type: 'application/zip' else - send_data @concept_map.to_json, filename: @concept_map.code+".json", type: :json + send_data @concept_map.to_json, filename: @concept_map.code + '.json', type: :json end - } - format.text { + end + format.text do if params.has_key?(:email) ConceptMapMailer.edited(params[:email], @map.code).deliver_later head :ok + else + if params.has_key?(:versions) + send_file @concept_map.to_zip(true), + filename: @concept_map.code + '.zip', + type: 'application/zip' else - if params.has_key?(:versions) - send_file @concept_map.to_zip(true), filename:@concept_map.code+".zip", type: "application/zip" - else - send_data @concept_map.to_tgf, filename: @concept_map.code+".tgf", type: :text - end + send_data @concept_map.to_tgf, filename: @concept_map.code + '.tgf', type: :text + end end - } + end end end # GET /concept_maps/new def new - respond_to do |format| - format.js { - } - format.zip { - @concept_map = ConceptMap.new - render 'import.js.erb', content_type: Mime::JS - } + if params['import'].nil? + render 'create_concept_map', layout: 'backend' + else + @concept_map = ConceptMap.create + render 'import_concept_map', layout: 'backend' end end + # PUT /concept_maps/1 + def update + render error: { error: 'unable to update' }, status: 400 if !@concept_map.update(params) + end + # GET /concept_maps/1/edit def edit - if @concept_map.accesses.nil? - @concept_map.accesses = 0 - end + # project is needed to check whether coworking is enabled + survey = Survey.find_by_id(@concept_map.survey_id) + @project = Project.find_by_id(survey.project_id) + + @concept_map.accesses = 0 if @concept_map.accesses.nil? @concept_map.accesses = @concept_map.accesses + 1 @concept_map.save + + # if concepts were added to the survey preset after creation, + # and the map is still untouched, insert them now + @concept_map.after_create if !@concept_map.has_concepts && !survey.initial_map.blank? + render 'edit' end + # called from application_controller#check_login_frontend if a full edit url is supplied + def redirect_to_edit + map = ConceptMap.find_by_code(params[:code]) + + @redirect_timestamp = DateTime.now.to_i + session[:map] = map.id + render 'redirect_to_edit', layout: 'application' + end + + def confirm_redirect + redirect_to '/' and return if DateTime.now.to_i - params[:timestamp].to_i < 5 + redirect_to '/' and return if params[:dont_fill_username].present? + redirect_to '/' and return if params[:dont_fill_password].present? + + redirect_to edit_concept_map_path(code: params[:code]) + end + # POST /concept_maps def create - respond_to do |format| - format.js { - res = I18n.t('error') - if params[:type] == "simple" - count = params[:number].to_i || 0 - res = I18n.t('concept_maps.create') + ":
" - count.times do - cm = @survey.concept_maps.build - cm.save - res = res + cm.code + "
" - end - else - if params[:type] == "email" - anonymous = params[:anonymized] == '1' - list = params[:email] - codes = [] - list.split("\n").each do |email| + if params.has_key?(:number) || params.has_key?(:email) + res = I18n.t('error') + if params[:type] == 'simple' + # create number of concept maps + count = params[:number].to_i || 0 + res = I18n.t('concept_maps.create') + ':
' + count.times do + cm = @survey.concept_maps.build + cm.save + res = res + cm.code + '
' + end + else + # create concepts maps and mail them + if params[:type] == 'email' + anonymous = params[:anonymized] == '1' + list = params[:email] + codes = [] + list + .split("\n") + .each do |email| cm = @survey.concept_maps.build cm.save codes = codes + [[email, cm.code]] ConceptMapMailer.created(email, cm.code, anonymous).deliver_later end - res = I18n.t('concept_maps.create') + ":
" - if (anonymous) - codes.map{|x| x[1]}.sort.each do |c| - res = res + c + "
" - end - else - res = res + "" - codes.each do |c| - res = res + "" - end - res = res + "
" + c[1] + "" + c[0] + "
" - end - end - end - redirect_to user_project_survey_path(@user, @project, @survey), notice: res - } - format.html { - if params.has_key?(:concept_map) && !params[:concept_map][:file].nil? - res = true - params[:concept_map][:file].each do |f| - @concept_map = @survey.concept_maps.build - res = res && @concept_map.import_file(f.tempfile, f.original_filename.split('.')[0]) - end - end - if res - if params[:concept_map][:file].size == 1 - redirect_to user_project_survey_concept_map_path(@user, @project, @survey, @concept_map), notice: I18n.t('concept_maps.imported') + res = I18n.t('concept_maps.create') + ':
' + if (anonymous) + codes.map { |x| x[1] }.sort.each { |c| res = res + c + '
' } else - redirect_to user_project_survey_concept_maps_path(@user, @project, @survey), notice: I18n.t('concept_maps.imported') + res = res + "" + codes.each { |c| res = res + '' } + res = res + '
' + c[1] + '' + c[0] + '
' end - else - redirect_to user_project_survey_concept_maps_path(@user, @project, @survey), notice: I18n.t('error_import') end - } + end + redirect_to user_project_survey_path(@user, @project, @survey), notice: res + return end - end + # import concept maps + if params.has_key?(:concept_map) && !params[:concept_map][:file].nil? + res = true + params[:concept_map][:file].each do |f| + @concept_map = @survey.concept_maps.build + res = res && @concept_map.import_file(f.tempfile, f.original_filename.split('.')[0]) + end + end + if res + if params[:concept_map][:file].size == 1 + redirect_to user_project_survey_concept_map_path(@user, @project, @survey, @concept_map), + notice: I18n.t('concept_maps.imported') + else + redirect_to user_project_survey_path(@user, @project, @survey), + notice: I18n.t('concept_maps.imported') + end + else + redirect_to user_project_survey_concept_maps_path(@user, @project, @survey), + notice: I18n.t('error_import') + end + end # DELETE /concept_maps/1 def destroy @@ -139,29 +181,37 @@ def destroy end private - #Load concept maps and check whether user is allowed to access it (frontend or backend) - def set_concept_map - @concept_map = ConceptMap.find_by_code(params[:code]) - if @concept_map.nil? || (@concept_map != @map && @concept_map.survey.project.user != @user) - redirect_to '/' - end + + #Load concept maps and check whether user is allowed to access it (backend) + def set_concept_map + @concept_map = ConceptMap.find_by_code(params[:code]) + if @concept_map.nil? || (@concept_map != @map && @concept_map.survey.project.user != @user) + redirect_to '/' end + end - def set_user_project_survey - @user = User.find(params[:user_id]) - if @user.nil? || (@user.id != @login.id && !@login.admin?) - redirect_to '/backend' - end - @project = Project.find(params[:project_id]) - if @project.nil? || @project.user != @user - redirect_to '/backend' - else - @survey = Survey.find(params[:survey_id]) - if @survey.nil? || @survey.project != @project - redirect_to '/backend' - end - end + # Load concept map; redirect to bot trap if map id in session is different the one in the params + def set_concept_map_frontend + @concept_map = ConceptMap.find_by_code(params[:code]) + if @concept_map.nil? + redirect_to '/' + elsif @concept_map != @map + redirect_to controller: 'concept_maps', action: 'redirect_to_edit', code: params[:code] and + return end + end + + def set_user_project_survey + @user = User.find(params[:user_id]) + redirect_to root_path if @user.nil? || (@user.id != @login.id && !@login.admin?) + @project = Project.find(params[:project_id]) + if @project.nil? || (@project.user != @user && !@login.admin?) + redirect_to root_path + else + @survey = Survey.find(params[:survey_id]) + redirect_to root_path if @survey.nil? || @survey.project != @project + end + end def login_for_show if params.has_key?(:project_id) @@ -172,4 +222,7 @@ def login_for_show end end + def concept_maps_params + params.require(:concept_map).permit([concepts_attributes: %i[id label shape color x y lock]]) + end end diff --git a/app/controllers/concepts_controller.rb b/app/controllers/concepts_controller.rb index b4b85b09..c3b6cefc 100644 --- a/app/controllers/concepts_controller.rb +++ b/app/controllers/concepts_controller.rb @@ -1,65 +1,96 @@ class ConceptsController < ApplicationController - skip_before_action :check_login_backend before_action :set_concept_map - before_action :set_concept, only: [:edit, :update, :destroy] + before_action :set_concept, only: %i[edit update destroy] - # POST /concept_maps/1/concepts.js + # POST /concept_maps/1/concepts def create @concept = @map.concepts.build(concept_params) - respond_to do |format| - if @concept.save - @map.versionize(DateTime.now) - format.js {} - else - format.js {head :ok} - end - end + @map.versionize(DateTime.now) if @concept.save + + # DH: Update the student "updatet_at" (to show his last action) + propagate_to_subscribers('create') end - # PATCH/PUT /concept_maps/1/concepts/1.js + # PATCH/PUT /concept_maps/1/concepts/1 def update - respond_to do |format| - old = @concept.label - if @concept.update(concept_params) - unless concept_params[:label] == old - @map.versionize(DateTime.now) - end - format.js { } - else - format.js { head :ok } - end + old_data = { + old_label: @concept.label, + old_x: @concept.x, + old_y: @concept.y, + old_color: @concept.color, + old_shape: @concept.shape, + old_lock: @concept.lock + } + + if @concept.update(concept_params) + @map.versionize(DateTime.now) unless concept_params[:label] == old_data['old_label'] end + render :create + + propagate_to_subscribers('update', old_data) end # DELETE /concept_maps/1/concepts/1.js def destroy @concept.destroy @map.versionize(DateTime.now) - respond_to do |format| - format.js {} - end + head :ok + + propagate_to_subscribers('destroy') end private + def propagate_to_subscribers(action, old_data = {}) + return if !session[:student_id].present? + + Student.update(@current_student.id, updated_at: DateTime.now) + + data = { + action: action, + type: 'node', + user: @current_student.name, + user_id: @current_student.id, + user_color: @current_student.color, + label: @concept.label, + id: @concept.id, + x: @concept.x, + y: @concept.y, + color: @concept.color, + shape: @concept.shape, + map_id: @map.id + } + + if action == 'update' + data.merge!( + { + lock: @concept.lock, + label_old: old_data['old_label'], + x_old: old_data['old_x'], + y_old: old_data['old_y'], + color_old: old_data['old_color'], + lock_old: old_data['old_lock'], + map_id: @map.id + } + ) + end + + ActionCable.server.broadcast('test_channel', data) + end + def set_concept @concept = Concept.find(params[:id]) - unless !@concept.nil? && @concept.concept_map == @concept_map - redirect_to '/' - end + redirect_to '/' unless !@concept.nil? && @concept.concept_map == @concept_map end def set_concept_map @concept_map = ConceptMap.find_by_code(params[:concept_map_code]) - unless !@concept_map.nil? && @concept_map == @map - redirect_to '/' - end + redirect_to '/' unless !@concept_map.nil? && @concept_map == @map end # Never trust parameters from the scary internet, only allow the white list through. def concept_params - params.fetch(:concept, {}).permit([:label, :x, :y, :color]) + params.require(:concept).permit(%i[label lock x y color shape]) end - end diff --git a/app/controllers/links_controller.rb b/app/controllers/links_controller.rb index 262be23e..5ccc3e4f 100644 --- a/app/controllers/links_controller.rb +++ b/app/controllers/links_controller.rb @@ -1,62 +1,82 @@ class LinksController < ApplicationController - skip_before_action :check_login_backend before_action :set_concept_map - before_action :set_link, only: [:edit, :update, :destroy] + before_action :set_link, only: %i[edit update destroy] - # POST /concept_maps/1/links.js + # POST /concept_maps/1/links def create - @link = @map.links.build(label: link_params[:label], start_id: link_params[:start].to_i, end_id: link_params[:end].to_i) - respond_to do |format| - if @link.save - @map.versionize(DateTime.now) - format.js {} - else - format.js {head :ok} - end - end + @link = @map.links.build(link_params) + @map.versionize(DateTime.now) if @link.save + + propagate_to_subscribers('create') end - # PATCH/PUT /concept_maps/1/links/1.js + # PATCH/PUT /concept_maps/1/links/1 def update - respond_to do |format| - if @link.update(link_params.permit(:label)) - @map.versionize(DateTime.now) - format.js {} - else - format.js { head :ok } - end - end + old_data = { label_old: @link.label, lock_old: @link.lock } + + #DH: Add the lock parameter + @map.versionize(DateTime.now) if @link.update(link_params.permit(:label, :lock)) + + propagate_to_subscribers('update', old_data) + render :create end - # DELETE /concept_maps/1/links/1.js + # DELETE /concept_maps/1/links/1 def destroy + propagate_to_subscribers('destroy') + @link.destroy @map.versionize(DateTime.now) - respond_to do |format| - format.js {} - end + head :ok end private + def propagate_to_subscribers(action, old_data = {}) + return if !session[:student_id].present? + + Student.update(@current_student.id, updated_at: DateTime.now) + + data = { + map_id: @map.id, + action: action, + type: 'link', + user_id: @current_student.id, + user: @current_student.name, + user_color: @current_student.color, + id: @link.id, + label: @link.label + } + + if action == 'update' + data.merge!( + { + lock: @link.lock, + lock_old: old_data['lock_old'], + label_old: old_data['label_old'], + start: @link.start_id, + end: @link.end_id + } + ) + end + + ActionCable.server.broadcast('test_channel', data) + end + def set_link @link = Link.find(params[:id]) - unless !@link.nil? && @link.concept_map == @concept_map - redirect_to '/' - end + redirect_to '/' unless !@link.nil? && @link.concept_map == @concept_map end def set_concept_map @concept_map = ConceptMap.find_by_code(params[:concept_map_code]) - unless !@concept_map.nil? && @concept_map == @map - redirect_to '/' - end + redirect_to '/' unless !@concept_map.nil? && @concept_map == @map end # Never trust parameters from the scary internet, only allow the white list through. def link_params - params.fetch(:link, {}).permit([:label, :start, :end]) + # DH: Add the lock parameter + params.require(:link).permit(%i[label lock start_id end_id]) end - end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index d8798858..ae0ceb34 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -1,13 +1,17 @@ class ProjectsController < ApplicationController before_action :set_user - before_action :set_project, only: [:show, :edit, :update, :destroy] + before_action :set_project, only: %i[show edit update destroy] skip_before_action :check_login_frontend layout 'backend' # GET /projects def index - @projects = @user.projects.order(created_at: :desc) + if @login.admin? + @projects = Project.all + else + @projects = @user.projects.order(created_at: :desc) + end end # GET /projects/1 @@ -17,109 +21,110 @@ def show @surveys = @project.surveys.order(created_at: :desc) respond_to do |format| format.html {} - format.text { + format.text do if params.has_key?(:versions) - send_file @project.to_zip(true, true), filename: @project.name+".zip", type: "application/zip" + send_file @project.to_zip(true, true), + filename: @project.name + '.zip', + type: 'application/zip' else - send_file @project.to_zip(true, false), filename: @project.name+".zip", type: "application/zip" + send_file @project.to_zip(true, false), + filename: @project.name + '.zip', + type: 'application/zip' end - } - format.json { + end + format.json do if params.has_key?(:versions) - send_file @project.to_zip(false, true), filename: @project.name+".zip", type: "application/zip" + send_file @project.to_zip(false, true), + filename: @project.name + '.zip', + type: 'application/zip' else - send_file @project.to_zip(false, false), filename: @project.name+".zip", type: "application/zip" + send_file @project.to_zip(false, false), + filename: @project.name + '.zip', + type: 'application/zip' end - } + end end end - # GET /projects/new.js + # GET /projects/new.html def new @project = Project.new - respond_to do |format| - format.js { - } - format.zip { - render 'import.js.erb', content_type: Mime::JS - } + if params['import'].nil? + render 'create_project' + else + render 'import_project' end end - # GET /projects/1/edit.js - def edit - end + def import; end - # POST /projects.js + # GET /projects/1/edit.html + def edit; end + + # POST /projects.html def create - respond_to do |format| - format.js { - @project = @user.projects.build(project_params) - if @project.save - redirect_to user_project_path(@user, @project) - else - render :new - end - } - format.html { - if params.has_key?(:project) && !params[:project][:file].nil? - res = true - params[:project][:file].each do |f| - @project = @user.projects.build - res = res && @project.import_file(f.tempfile) - end - end - if res - if params[:project][:file].size == 1 - redirect_to user_project_path(@user, @project), notice: I18n.t('projects.imported') - else - redirect_to user_projects_path(@user), notice: I18n.t('projects.imported') - end - else - redirect_to user_projects_path(@user), notice: I18n.t('error_import') - end - } + # create project from form inputs + if params.has_key?(:project) && params[:project][:file].nil? + @project = @user.projects.build(project_params) + if @project.save + redirect_to user_project_path(@user, @project) + else + render :new + end + return end - end - # PATCH/PUT /projects/1.js - def update - respond_to do |format| - if @project.update(project_params) - format.js {} + # create projects from input file(s) + if params.has_key?(:project) && !params[:project][:file].nil? + res = true + params[:project][:file].each do |f| + @project = @user.projects.build + res = res && @project.import_file(f.tempfile) + end + end + + if res + if params[:project][:file].size == 1 + redirect_to user_project_path(@user, @project), notice: I18n.t('projects.imported') else - format.js { render :edit } + redirect_to user_projects_path(@user), notice: I18n.t('projects.imported') end + else + redirect_to user_projects_path(@user), notice: I18n.t('error_import') + end + end + + # PATCH/PUT /projects/1 + def update + if @project.update(project_params) + redirect_to user_project_path(@user, @project) + else + render edit_user_project_path(@user, @project), notice: I18n.t('error') end end # DELETE /projects/1 def destroy @project.destroy - respond_to do |format| - format.html { redirect_to user_projects_path(@user), notice: I18n.t('projects.destroyed')} - end + redirect_to user_projects_path(@user), notice: I18n.t('projects.destroyed'), status: :see_other end private - # Use callbacks to share common setup or constraints between actions. + + # Use callbacks to share common setup or constraints between actions. def set_user @user = User.find(params[:user_id]) - if @user.nil? || (@user.id != @login.id && !@login.admin?) - redirect_to '/backend' - end + redirect_to root_path if @user.nil? || (@user.id != @login.id && !@login.admin?) end - def set_project - @project = Project.find(params[:id]) - if @project.nil? || (@project.user != @user) - redirect_to '/backend' - end - end + def set_project + @project = Project.find(params[:id]) + redirect_to root_path if @project.nil? || (!@login.admin? && @project.user != @user) + end - # Never trust parameters from the scary internet, only allow the white list through. - def project_params - params.fetch(:project, {}).permit([:name, :description]) - end + # Never trust parameters from the scary internet, only allow the white list through. + def project_params + params.fetch(:project, {}).permit(%i[name description enable_coworking]) + end end diff --git a/app/controllers/surveys_controller.rb b/app/controllers/surveys_controller.rb index 5a6e7f34..0bcf6a6e 100644 --- a/app/controllers/surveys_controller.rb +++ b/app/controllers/surveys_controller.rb @@ -1,6 +1,6 @@ class SurveysController < ApplicationController before_action :set_user_project - before_action :set_survey, only: [:show, :edit, :update, :destroy] + before_action :set_survey, only: %i[show edit update destroy] skip_before_action :check_login_frontend layout 'backend' @@ -15,117 +15,136 @@ def index # GET /surveys/1.json def show respond_to do |format| - format.html { + format.html do @maps = @survey.concept_maps.limit(10).order(updated_at: :desc) @page = 0 - } - format.text { + end + format.text do if params.has_key?(:versions) - send_file @survey.to_zip(true, true), filename: @survey.name+".zip", type: "application/zip" + send_file @survey.to_zip(true, true), + filename: @survey.name + '.zip', + type: 'application/zip' else - send_file @survey.to_zip(true, false), filename: @survey.name+".zip", type: "application/zip" + send_file @survey.to_zip(true, false), + filename: @survey.name + '.zip', + type: 'application/zip' end - } - format.json { + end + format.json do if params.has_key?(:versions) - send_file @survey.to_zip(false, true), filename: @survey.name+".zip", type: "application/zip" + send_file @survey.to_zip(false, true), + filename: @survey.name + '.zip', + type: 'application/zip' else - send_file @survey.to_zip(false, false), filename: @survey.name+".zip", type: "application/zip" + send_file @survey.to_zip(false, false), + filename: @survey.name + '.zip', + type: 'application/zip' end - } + end end end # GET /surveys/new.js def new @survey = Survey.new - respond_to do |format| - format.js { - } - format.zip { - render 'import.js.erb', content_type: Mime::JS - } + if params['import'].nil? + render 'create_survey' + else + render 'import_survey' end end # GET /surveys/1/edit.js def edit + if params[:detail].nil? + render 'edit' + else + render 'edit_detail' + end end # POST /surveys def create - respond_to do |format| - format.js { - @survey = @project.surveys.build(survey_params) - if @survey.save - redirect_to user_project_survey_path(@user, @project, @survey) - else - render :new - end - } - format.html { - if params.has_key?(:survey) && !params[:survey][:file].nil? - res = true - params[:survey][:file].each do |f| - @survey = @project.surveys.build - res = res && @survey.import_file(f.tempfile) - end - end - if res - if params[:survey][:file].size == 1 - redirect_to user_project_survey_path(@user, @project, @survey), notice: I18n.t('surveys.imported') - else - redirect_to user_project_surveys_path(@user, @project), notice: I18n.t('surveys.imported') - end - else - redirect_to user_project_surveys_path(@user, @project), notice: I18n.t('error_import') - end - } + # no file was passed -> create from form input + if params.has_key?(:survey) && params[:survey][:file].nil? + @survey = @project.surveys.build(survey_params) + logger.info @survey.inspect + if @survey.save + redirect_to user_project_survey_path(@user, @project, @survey) + else + render :new + end + return end - end - # PATCH/PUT /surveys/1.js - def update - respond_to do |format| - if @survey.update(survey_params) - format.js{} - format.html {redirect_to user_project_survey_path(@user, @project, @survey)} + # one or more files were passed -> import + if params.has_key?(:survey) && !params[:survey][:file].nil? + res = true + params[:survey][:file].each do |f| + @survey = @project.surveys.build + res = res && @survey.import_file(f.tempfile) + end + end + if res + if params[:survey][:file].size == 1 + redirect_to user_project_survey_path(@user, @project, @survey), + notice: I18n.t('surveys.imported') else - format.js { render :edit } + redirect_to user_project_path(@user, @project), notice: I18n.t('surveys.imported') end + else + redirect_to user_project_surveys_path(@user, @project), notice: I18n.t('error_import') + end + end + + # PATCH/PUT /surveys/1 + def update + if @survey.update(survey_params) + redirect_to user_project_survey_path(@user, @project, @survey) + else + redirect_to edit_user_project_survey_path(@user, @project, @survey), notice: I18n.t('error') end end # DELETE /surveys/1 def destroy @survey.destroy - respond_to do |format| - format.html { redirect_to user_project_path(@user, @project), notice: I18n.t('surveys.destroyed') } - end + redirect_to user_project_path(@user, @project), + notice: I18n.t('surveys.destroyed'), + status: :see_other end private - # Use callbacks to share common setup or constraints between actions. - def set_survey - @survey = Survey.find(params[:id]) - if @survey.nil? || @survey.project != @project - redirect_to '/backend' - end - end - def set_user_project - @user = User.find(params[:user_id]) - if @user.nil? || (@user.id != @login.id && !@login.admin?) - redirect_to '/backend' - end - @project = Project.find(params[:project_id]) - if @project.nil? || (@project.user != @user && !@user.admin?) - redirect_to '/backend' - end - end + # Use callbacks to share common setup or constraints between actions. + def set_survey + @survey = Survey.find(params[:id]) + redirect_to root_path if @survey.nil? || @survey.project != @project + end - # Never trust parameters from the scary internet, only allow the white list through. - def survey_params - params.fetch(:survey, {}).permit([:name, :description, :code, :start_date, :end_date, :introduction, :concept_labels, :association_labels, :initial_map]) - end + def set_user_project + @user = User.find(params[:user_id]) + redirect_to root_path if @user.nil? || (@user.id != @login.id && !@login.admin?) + @project = Project.find(params[:project_id]) + redirect_to root_path if @project.nil? || (@project.user != @user && !@user.admin?) + end + + # Never trust parameters from the scary internet, only allow the white list through. + def survey_params + params + .fetch(:survey, {}) + .permit( + %i[ + name + description + code + start_date + end_date + introduction + concept_labels + association_labels + initial_map + ] + ) + end end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 9177d484..a067d0ba 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -1,6 +1,6 @@ class UsersController < ApplicationController - before_action :is_allowed, only: [:create, :index, :new] - before_action :set_user, only: [:edit, :update, :destroy, :show] + before_action :is_allowed, only: %i[create index new] + before_action :set_user, only: %i[edit update destroy show] skip_before_action :check_login_frontend layout 'backend' @@ -11,8 +11,7 @@ def index end # GET /users/1 - def show - end + def show; end # GET /users/new def new @@ -21,14 +20,13 @@ def new end # GET /users/1/edit - def edit - end + def edit; end # POST /users def create p = user_params if p[:password].blank? - p[:password] = ConceptMap.generate_slug() + p[:password] = ConceptMap.generate_slug p[:password_confirmation] = p[:password] end @user = User.new(p) @@ -38,7 +36,7 @@ def create UserMailer.created(@user.email, p[:password]).deliver_later format.html { redirect_to users_path, notice: I18n.t('users.created') } else - format.html { render :edit } + format.html { render :edit, status: :unprocessable_entity } end end end @@ -49,7 +47,7 @@ def update if @user.update(user_params) format.html { redirect_to @user, notice: I18n.t('users.updated') } else - format.html { render :edit } + format.html { render :edit, status: :unprocessable_entity } end end end @@ -58,31 +56,28 @@ def update def destroy @user.destroy respond_to do |format| - format.html { redirect_to users_url, notice: I18n.t('users.destroyed') } + format.html { redirect_to users_url, notice: I18n.t('users.destroyed'), status: :see_other } end end private - # Use callbacks to share common setup or constraints between actions. - def is_allowed - if !@login.admin? - redirect_to '/backend' - end - end - def set_user - @user = User.find(params[:id]) - if @user.nil? || (@user.id != @login.id && !@login.admin?) - redirect_to '/backend' - end - end + # Use callbacks to share common setup or constraints between actions. + def is_allowed + redirect_to root_path if !@login.admin? + end - # Never trust parameters from the scary internet, only allow the white list through. - def user_params - if @login.admin? - params.fetch(:user, {}).permit([:email, :password, :password_confirmation, :capabilities]) - else - params.fetch(:user, {}).permit([:email, :password, :password_confirmation]) - end + def set_user + @user = User.find(params[:id]) + redirect_to root_path if @user.nil? || (@user.id != @login.id && !@login.admin?) + end + + # Never trust parameters from the scary internet, only allow the white list through. + def user_params + if @login.admin? + params.fetch(:user, {}).permit(%i[email password password_confirmation capabilities]) + else + params.fetch(:user, {}).permit(%i[email password password_confirmation]) end + end end diff --git a/app/controllers/versions_controller.rb b/app/controllers/versions_controller.rb index 1b6098b5..6f05c816 100644 --- a/app/controllers/versions_controller.rb +++ b/app/controllers/versions_controller.rb @@ -3,7 +3,7 @@ class VersionsController < ApplicationController before_action :set_version_and_check - #GET /concept_maps/1/version/1.js + #GET /concept_maps/1/version/1.json def show end @@ -21,11 +21,11 @@ def destroy def set_version_and_check @concept_map = ConceptMap.find_by_code(params[:concept_map_code]) if @concept_map.nil? || (@concept_map.survey.project.user != @login && !@login.admin?) - redirect_to '/backend' + head 401 end @version = @concept_map.versions[params[:id].to_i] if @version.nil? - redirect_to '/backend' + head 404 end end end diff --git a/app/frontend/channels/consumer.js b/app/frontend/channels/consumer.js new file mode 100644 index 00000000..758d71c1 --- /dev/null +++ b/app/frontend/channels/consumer.js @@ -0,0 +1,9 @@ +// Action Cable provides the framework to deal with WebSockets in Rails. +// You can generate new channels where WebSocket features live using the `bin/rails generate channel` command. + +import { createConsumer } from '@rails/actioncable' + +//export default createConsumer('wss://comaped.de/cable') + +// development environment +export default createConsumer() diff --git a/app/frontend/channels/index.js b/app/frontend/channels/index.js new file mode 100644 index 00000000..52008871 --- /dev/null +++ b/app/frontend/channels/index.js @@ -0,0 +1,2 @@ +// Import all the channels to be used by Action Cable +import "channels/test_channel" diff --git a/app/frontend/channels/test_channel.js b/app/frontend/channels/test_channel.js new file mode 100644 index 00000000..055ca368 --- /dev/null +++ b/app/frontend/channels/test_channel.js @@ -0,0 +1,465 @@ +import consumer from './consumer' +import { getLanguage } from '../js/helpers' + +// TODO really needed? +// If the user has other Subscriptions, make sure to remove them: Might be useful in the future +consumer.subscriptions.subscriptions.forEach(subscription => { + consumer.subscriptions.remove(subscription) +}) + +// Create the TestChannel subscription, called from initEditor in entrypoints/application.js +export const initSubscription = () => { + consumer.notifications = consumer.subscriptions.create( + { channel: 'TestChannel', map_id: '34' }, + { + connected() { + // Called when the subscription is ready for use on the server + console.log('Connected!') + }, + + disconnected() { + // Called when the subscription has been terminated by the server + console.log('Terminated') + }, + + ping: function () { + return this.perform('ping', { + message: { event: 'ping' }, + }) + }, + + // Called when there's incoming data on the websocket for this channel + received(data) { + // function get the browser language of the user + let language = getLanguage() + + // function to create or update a node + function updateNode(id, label, x, y, color, lock, user_color, shape) { + let border = color + let borderWidth = 2 + if (lock) { + // change color and border for locked nodes + color = '#9B9B9B' + border = user_color + borderWidth = 5 + } + // create or update the Node with the parameters + window.setNodeData({ + id: id, + label: label, + shape: shape, + borderWidth: borderWidth, + lock: lock, + x: x, + y: y, + color: { + background: color, + border: border, + hover: { + background: color, + border: border, + }, + highlight: { + background: color, + border: 'black', + }, + }, + }) + } + + // function to create or update an edge + function updateEdge(id, from, to, label, lock, user_color) { + // decide where to set the label + let align = 'top' + if (from < to) { + align = 'bottom' + } + + // Check the lock: + let color = '#a0a0a0' + let hover = '#808080' + let highlight = '#808080' + if (lock) { + color = user_color + hover = user_color + highlight = user_color + } + // create or update the edge with the parameters + window.setEdgeData({ + id: id, + from: from, + to: to, + label: label, + lock: lock, + font: { + align: align, + }, + labelHighlightBold: false, + arrowStrikethrough: false, + color: { + color: color, + hover: hover, + highlight: highlight, + }, + hoverWidth: 0.5, + }) + } + + // Get the current map + let map_id = $('#map_id').html() + + // Only if the map of the current user is the same as the map of the sender, we should update the canvas + if (parseInt(data['map_id'], 10) === parseInt(map_id, 10)) { + // Get the current user + let current_user = $('#user_me_id').html() + + // Check who did an update + if (parseInt(data['user_id'], 10) !== parseInt(current_user, 10)) { + // An other user did the change + switch (data['action']) { + case 'user_left': // user left intentionally by logout + case 'user_disconnected': // user was disconnected by timeout or closed window + //TODO user_disconnected should be handled differently, as a disconnection + //TODO can occur for various reasons, e. g. phone goes to sleep, loses network + //TODO connection, or the user closes the browser. The backend will delete the + //TODO student on disconnect, so for the first two cases, it might be better to + //TODO store the student name frontend-side, and silently create a new user with + //TODO this name in the backend. Checks for user id must then be switched to checks + //TODO for user name. + + // the user must be removed of the active users + $('#student_' + data['user_id']).remove() + + // Update the log + // All the changes the user did will be now under filter_left + $('*[data-student="filter_' + data['user_id'] + '"]').attr( + 'data-student', + 'filter_left' + ) + + // Show the filter option for the left students + $('#filter_left_students').show() + break + + case 'user_joined': + // add the user to the active users + let new_user_id = data['user_id'] + let new_user_name = data['user'] + let new_user_color = data['user_color'] + let new_user = + '
' + $('#students').html($('#students').html() + new_user) + break + + case 'create': + if (data['type'] == 'node') { + // Display the new node + updateNode( + data['id'], + data['label'], + data['x'], + data['y'], + data['color'], + data['shape'] + ) + } else if (data['type'] == 'link') { + // Display the new link + updateEdge(data['id'], data['start'], data['end'], data['label']) + } + break + + case 'update': + if (data['type'] == 'node') { + // Display the updated node + updateNode( + data['id'], + data['label'], + data['x'], + data['y'], + data['color'], + data['lock'], + data['user_color'], + data['shape'] + ) + } else if (data['type'] == 'link') { + // Display the updated link + updateEdge( + data['id'], + data['start'], + data['end'], + data['label'], + data['lock'], + data['user_color'] + ) + } + break + + case 'destroy': + if (data['type'] == 'node') { + // Destroy the node on the canvas + window.nodes.remove(data['id']) + if ( + $('#edit-dialog').is(':visible') && + ($('#start').val() == data['id'] || $('#end').val() == data['id']) + ) { + // Close the edit-dialog + $('#edit-dialog').hide() + // IMPORTANT: Set the working mode to none (0), otherwise another object can't be edit + window.setMode(0) + } + } else if (data['type'] == 'link') { + // Destroy the link on the canvas + window.edges.remove(data['id']) + } + break + default: + break + } // end switch + } else { + // Current user did the change + // Check if the current user left the map + if (data['action'] == 'user_left') { + // current_student left the map -> opened new window + let html = '' + if (language == 'de') { + html = + '
Du hast ComapEd in einem anderen Fenster geöffnet. Du kannst dieses Fenster nun schließen!
' + } else { + html = + '
You opened ComapEd in another browser tab. Close this tab, please
' + } + // overwrite the canvas + $('body').html(html) + } + } + + // Update the recent changes container + let log = '' + const user = data['user']?.split('-').join(' ') + + // Object Header + let object = '' + + // The changes done to the object + let change = '' + + // Fill the data + switch (data['action']) { + case 'create': + if (data['type'] == 'node') { + if (language == 'de') { + object = 'Konzept erstellt' + change += '
  • erstellte' + ' das Konzept ' + data['label'] + '
  • ' + change += + "
  • die Farbe ist (" + + data['color'] + + ')
  • ' + change += + '
  • die Position lautet [x: ' + + Math.round(parseInt(data['x'])) + + '; y: ' + + Math.round(parseInt(data['y'])) + + ']
  • ' + } else { + object = 'Created Node' + change += '
  • created' + ' the node ' + data['label'] + '
  • ' + change += + "
  • the color is (" + + data['color'] + + ')
  • ' + change += + '
  • the position is [x: ' + + Math.round(parseInt(data['x'])) + + '; y: ' + + Math.round(parseInt(data['y'])) + + ']
  • ' + } + } else if (data['type'] == 'link') { + if (language == 'de') { + object = 'Verbindung erstellt' + change += '
  • erstellte' + ' die Verbindung ' + data['label'] + '
  • ' + } else { + object = 'Created Edge' + change += '
  • created' + ' the edge ' + data['label'] + '
  • ' + } + } + break + + case 'update': + if (data['type'] == 'node') { + // Update node + if (language == 'de') { + object = 'Updated Konzept "' + data['label_old'] + '"' + } else { + object = 'Updated Node "' + data['label_old'] + '"' + } + + if (data['label_old'] != data['label']) { + if (language == 'de') { + change += '
  • änderte das Label zu "' + data['label'] + '"
  • ' + } else { + change += '
  • set the label to "' + data['label'] + '"
  • ' + } + } + if (data['color_old'] != data['color']) { + if (language == 'de') { + change += + "
  • änderte die Farbe zu (" + + data['color'] + + ')
  • ' + } else { + change += + "
  • set the color to (" + + data['color'] + + ')
  • ' + } + } + if (data['x_old'] != data['x'] || data['y_old'] != data['y']) { + if (language == 'de') { + change += + '
  • änderte die Position zu [x: ' + + Math.round(parseInt(data['x'])) + + '; y: ' + + Math.round(parseInt(data['y'])) + + ']
  • ' + } else { + change += + '
  • set the position to [x: ' + + Math.round(parseInt(data['x'])) + + '; y: ' + + Math.round(parseInt(data['y'])) + + ']
  • ' + } + } + if (data['lock_old'] != data['lock']) { + if (data['lock']) { + if (language == 'de') { + change += '
  • sperrt das Konzept
  • ' + } else { + change += '
  • set a lock
  • ' + } + } else { + if (language == 'de') { + change += '
  • entsperrt das Konzept
  • ' + } else { + change += '
  • removed the lock
  • ' + } + } + } + } else if (data['type'] == 'link') { + // Update edge + if (language == 'de') { + object = 'Update Verbindung "' + data['label_old'] + '"' + } else { + object = 'Update Edge "' + data['label_old'] + '"' + } + + if (data['label_old'] != data['label']) { + if (language == 'de') { + change += '
  • änderte das Label zu ' + data['label'] + '
  • ' + } else { + change += '
  • set the label to ' + data['label'] + '
  • ' + } + } + if (data['lock_old'] != data['lock']) { + if (data['lock']) { + if (language == 'de') { + change += '
  • sperrt die Verbindung
  • ' + } else { + change += '
  • set a lock
  • ' + } + } else { + if (language == 'de') { + change += '
  • entsperrt die Verbindung
  • ' + } else { + change += '
  • removed the lock
  • ' + } + } + } + } + break + + case 'destroy': + if (data['type'] == 'node') { + if (language == 'de') { + object = 'Konzept gelöscht "' + data['label'] + '"' + change += '
  • löschte das Konzept ' + data['label'] + '
  • ' + } else { + object = 'Deleted Node "' + data['label'] + '"' + change += '
  • deleted the node ' + data['label'] + '
  • ' + } + } else if (data['type'] == 'link') { + if (language == 'de') { + object = 'Verbindung gelöscht "' + data['label'] + '"' + change += '
  • löschte die Verbindung ' + data['label'] + '
  • ' + } else { + object = 'Deleted Edge "' + data['label'] + '"' + change += '
  • deleted the edge ' + data['label'] + '
  • ' + } + } + break + + default: + break + } // end switch + + //Check if it should show the new log -> depends on the filter + if ($('#filter_' + data['user_id']).is(':checked')) { + // Show the log + log = + '
    ' + + object + + '
    ' + + user + + '
      ' + + change + + '
    ' + } else { + // Hide the log + log = + '' + } + + if (change != '') { + $('#recent_changes').html(log + $('#recent_changes').html()) + } + } // end if (map_id == current map) + }, + } + ) + // if the connection is closed by the server, the student associated with the + // session is deleted. To avoid this, we ping every 30 seconds + setInterval(function () { + consumer.notifications.ping() + }, 30000) +} diff --git a/app/frontend/entrypoints/add_jquery.js b/app/frontend/entrypoints/add_jquery.js new file mode 100644 index 00000000..f1a04b29 --- /dev/null +++ b/app/frontend/entrypoints/add_jquery.js @@ -0,0 +1,3 @@ +import jquery from 'jquery' +window.jQuery = jquery +window.$ = jquery \ No newline at end of file diff --git a/app/frontend/entrypoints/application.js b/app/frontend/entrypoints/application.js new file mode 100644 index 00000000..fb75e4ed --- /dev/null +++ b/app/frontend/entrypoints/application.js @@ -0,0 +1,126 @@ +import './add_jquery' +import * as bootstrap from 'bootstrap' +import '@hotwired/turbo-rails' + +import ConceptMap from '../js/ConceptMap' +import BackendViewer from '../js/BackendViewer' +import StudentLog from '../js/StudentLog' +import '../channels/test_channel' + +import { initSubscription } from '../channels/test_channel' + +import 'bootstrap-icons/font/bootstrap-icons.css' + +import '../scss/application.scss' + +window.bootstrap = bootstrap + +/********************************** + * intantiates the editor object + *********************************/ +window.initEditor = ({ + edgeData, + nodeData, + conceptsPath, + conceptMapsPath, + linksPath, + enableCoworking, + dialogTexts, +}) => { + let isSubscriptionInitialized = false + const cm = new ConceptMap({ + edgeData, + nodeData, + conceptsPath, + conceptMapsPath, + linksPath, + dialogTexts, + enableCoworking, + }) + + // expose handlers needed for DOM events + window.network = cm.network + window.showForm = cm.showForm + window.hideForm = cm.hideForm + window.validateForm = cm.validateForm + window.searchConcept = cm.searchConcept + window.sendMail = cm.sendMail + window.destroy = cm.destroy + window.submitChanges = event => { + event.preventDefault() + event.stopPropagation() + cm.onSubmit() + return false + } + window.toast = cm.toast + window.edges = cm.edges + window.nodes = cm.nodes + window.canvasX = cm.canvasX + window.canvasY = cm.canvasY + window.changeColor = cm.changeColor + window.changeShape = cm.changeShape + window.changeEdgeShape = cm.changeEdgeShape + window.mode = cm.mode + window.buttonMode = cm.buttonMode + window.activeButton = cm.activeButton + window.createEdge = cm.createEdge + + window.setNodeData = cm.setNodeData + window.setEdgeData = cm.setEdgeData + + //DH Make the mode available + window.setMode = cm.setMode + + // init websocket connection only if enabled in the project settings + const isCoworkingEnabled = enableCoworking && enableCoworking !== 'false' + if (!isSubscriptionInitialized && isCoworkingEnabled) { + initSubscription() + isSubscriptionInitialized = true + } +} + +/********************************** + * instantiates Objects needed to display concept maps and timelines in admin area + *********************************/ +window.initBackend = ({ + edgeData, + nodeData, + id, + fetchUrl, + items, + firstTimestamp, + lastTimestamp, + maxVersion, +}) => { + window.viewers = window.viewers || {} + + // create an instance for the passed concept map id if none is present + if (!window.viewers[id]) { + window.viewers[id] = new BackendViewer({ id }) + } + + // function is called from the preview partial (either from the index/list or the show/detail page) + // this will init the data for the map itself. + if (edgeData && nodeData) { + window.viewers[id].setMapData({ id, edgeData, nodeData }) + } + + // function is called from the timeline partial. This will init the timeline data. + if (fetchUrl) { + window.viewers[id].setTimelineData({ + id, + fetchUrl, + items, + firstTimestamp, + lastTimestamp, + maxVersion, + }) + } + + return window.viewers[id] +} + +/********************************** + * instantiates student log display handlers + *********************************/ +new StudentLog() diff --git a/app/frontend/js/BackendViewer.js b/app/frontend/js/BackendViewer.js new file mode 100644 index 00000000..d133243e --- /dev/null +++ b/app/frontend/js/BackendViewer.js @@ -0,0 +1,172 @@ +import { Network } from 'vis-network' +import { DataSet } from 'vis-data' +import { Graph2d } from 'vis-timeline/standalone' + +class BackendViewer { + constructor() { + this.playing = false + this.timerID = -1 + this.timerId + this.version = 0 + this.maxVersion = 0 + + this.data = { + nodes: new DataSet([]), + edges: new DataSet([]), + } + } + + next = () => { + this.updatePreview(this.version + 1) + } + + previous = () => { + this.updatePreview(this.version - 1) + } + + updatePreview = async desiredVersion => { + this.version = desiredVersion + const url = this.fetchUrl + '/' + desiredVersion + '/' + const res = await fetch(url, { + method: 'GET', + mode: 'cors', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + }) + + // concept map was not found, or user has no privileges to see it -> redirect to login page + if (res.status === 401) { + location.replace('/backend') + } + // no versions found. This can happen if a concept map was imported without versions + if (res.status === 404) { + //TODO display message to user? + return + } + const body = await res.json() + + this.data.nodes.clear() + this.data.edges.clear() + this.data.nodes.update(body.nodes) + this.data.edges.update( + body.edges.map(node => ({ id: node.id, from: node.start_id, to: node.end_id, ...node })) + ) + + this.network.redraw() + + this.network.once('afterDrawing', () => { + this.network.fit() + }) + + $('#cur_ver').html(this.version + 1) + if (this.version === 0) { + $('#prev').attr('disabled', 'disabled') + } else { + $('#prev').removeAttr('disabled') + } + if (this.version === this.maxVersion) { + $('#next').attr('disabled', 'disabled') + } else { + $('#next').removeAttr('disabled') + } + + this.timeline.setCustomTime(body.timestamp, window.timeBar) + this.timeline.moveTo(body.timestamp) + } + + play = () => { + this.playing = !this.playing + + if (this.playing) { + $('#play').html("") + this.timerId = window.setInterval(() => { + if (this.version === this.maxVersion) { + this.play() + } else { + this.next() + } + }, 1500) + } else { + $('#play').html("") + window.clearInterval(this.timerId) + } + } + + timeChanged = properties => { + // find version next left from dropped cursor + const versionNextToCursor = this.items.reduce( + (acc, _item, index) => + Date.parse(this.items[index]['x']) <= Date.parse(properties.time) ? index : acc, + 0 + ) + + this.version = Math.max(0, versionNextToCursor) + this.updatePreview(versionNextToCursor) + } + + setMapData = ({ edgeData, nodeData, id }) => { + this.edgeData = edgeData + this.nodeData = nodeData + this.id = id + + const mapOptions = { + autoResize: true, + height: '100%', + width: '100%', + edges: { + smooth: { type: 'continuous' }, + }, + physics: { + enabled: false, + }, + interaction: { + hover: false, + dragNodes: false, + navigationButtons: false, + selectConnectedEdges: false, + hoverConnectedEdges: false, + }, + } + + const mapContainer = document.getElementById('canvas_' + this.id) + + this.data = { + nodes: new DataSet(this.nodeData), + edges: new DataSet(this.edgeData), + } + + this.network = new Network(mapContainer, this.data, mapOptions) + this.network.once('afterDrawing', () => { + this.network.fit() + }) + this.network.redraw() + } + + setTimelineData = ({ id, fetchUrl, items, firstTimestamp, lastTimestamp, maxVersion }) => { + this.fetchUrl = fetchUrl + this.items = items + this.firstTimestamp = firstTimestamp + this.lastTimestamp = lastTimestamp + this.id = id + this.maxVersion = maxVersion + + const timelineContainer = document.getElementById('timeline_' + id) + + const timelineData = new DataSet(items) + const timelineOptions = { + start: firstTimestamp, + end: lastTimestamp, + height: '100%', + width: '100%', + } + + this.timeline = new Graph2d(timelineContainer, timelineData, timelineOptions) + this.timeline.on('timechanged', this.timeChanged) + + this.timeline.addCustomTime(lastTimestamp) + } +} + +export default BackendViewer diff --git a/app/frontend/js/ConceptMap.js b/app/frontend/js/ConceptMap.js new file mode 100644 index 00000000..6ea58b84 --- /dev/null +++ b/app/frontend/js/ConceptMap.js @@ -0,0 +1,986 @@ +import $ from 'jquery' +import { Network } from 'vis-network' +import { DataSet } from 'vis-data' +import { getLanguage, ajax } from './helpers' + +class ConceptMap { + static none = 0 + static addNode = 1 + static editNode = 2 + static addEdge = 3 + static editEdge = 4 + static dragNode = 5 + static editMultiNode = 6 + + static nodeButton = 8 + static edgeButton = 9 + static editButton = 10 + + constructor({ + edgeData, + nodeData, + conceptsPath, + conceptMapsPath, + linksPath, + dialogTexts, + enableCoworking, + }) { + this.conceptsPath = conceptsPath + this.conceptMapsPath = conceptMapsPath + this.linksPath = linksPath + this.dialogTexts = dialogTexts + this.enableCoworking = enableCoworking + + // on small devices/phones, the keyboard will resize the canvas when it + // comes up, and again when it is closed, but somewhat unpredictable. + // This is to restore the original state. + this.scale = undefined + this.viewPosition = undefined + + this.edges = new DataSet(edgeData) + this.nodes = new DataSet(nodeData) + + this.container = $('#map-canvas')[0] + + this.mode = ConceptMap.none + this.buttonMode = ConceptMap.editButton //Start in Edit-Mode + this.activeButton(1) //Highlight Edit-Button + this.canvasX = 0 + this.canvasY = 0 + this.oldPointerX = 0 //Saves Pointerlocation when drag starts + this.oldPointerY = 0 + this.ids = [] + + this.hoveredNodeStyle = undefined + + /********************************** + * SETTINGS for Vis Network library + ***********************************/ + this.options = { + autoResize: true, + height: '100%', + width: '100%', + edges: { + smooth: { type: 'continuous' }, + }, + physics: { + // TBD: if uncollisioning is activated, all positions of all moved nodes have to be posted to the backend. + // TBD: currently, only the one moved by the user is posted. + /*barnesHut: { + springLength: 120, + springConstant: 0.0, + centralGravity: 0.0, + gravitationalConstant: -150, + avoidOverlap: 1, + damping: 0.25, + }, + solver: 'barnesHut',*/ + enabled: false, + }, + + interaction: { + hover: true, + navigationButtons: true, + selectConnectedEdges: false, + hoverConnectedEdges: false, + multiselect: true, + }, + manipulation: { + enabled: false, + initiallyActive: false, + addNode: true, + addEdge: (data, callback) => { + $('#addEdgeToast').fadeOut(500) + var connected_edges = [this.network.getConnectedEdges(data.from)] + for (let i = 0, len = connected_edges[0].length; i < len; i++) { + if ( + this.nodes.get(this.edges.get(connected_edges[0][i]).to).id == data.to && + this.nodes.get(this.edges.get(connected_edges[0][i]).from).id == data.from + ) { + this.network.disableEditMode() + this.activeButton(1) + this.buttonMode = ConceptMap.editButton + // if context_help is displayed show doubleEdgeToast instead for 6s then display context_help again + if (!$('#context-help').hasClass('d-none')) { + $('#context-help').addClass('d-none') + $('#doubleEdgeToast').fadeIn(500) + setTimeout(function () { + $('#doubleEdgeToast').fadeOut(500) + $('#context-help').removeClass('d-none') + $('#context-help-text').html($('#ch_editMode').html()) + }, 6000) + } else { + $('#doubleEdgeToast').fadeIn(500) + setTimeout(function () { + $('#doubleEdgeToast').fadeOut(500) + }, 6000) + } + return + } + } + this.createEdge(data) + }, + editEdge: true, + deleteNode: true, + deleteEdge: true, + }, + } + + this.network = new Network( + this.container, + { + nodes: this.nodes, + edges: this.edges, + }, + this.options + ) + + /******************************** + * Make EditForm movable + ********************************/ + dragElement(document.getElementById('edit-dialog')) + + function dragElement(elmnt) { + var pos1 = 0, + pos2 = 0, + pos3 = 0, + pos4 = 0 + if (document.getElementById(elmnt.id + '-header')) { + // if present, the header is where you move the DIV from: + document.getElementById(elmnt.id + '-header').onmousedown = dragMouseDown + } else { + // otherwise, move the DIV from anywhere inside the DIV: + elmnt.onmousedown = dragMouseDown + } + function dragMouseDown(e) { + e = e || window.event + e.preventDefault() + // get the mouse cursor position at startup: + pos3 = e.clientX + pos4 = e.clientY + document.onmouseup = closeDragElement + // call a function whenever the cursor moves: + document.onmousemove = elementDrag + } + function elementDrag(e) { + e = e || window.event + e.preventDefault() + // calculate the new cursor position: + pos1 = pos3 - e.clientX + pos2 = pos4 - e.clientY + pos3 = e.clientX + pos4 = e.clientY + // set the element's new position: + elmnt.style.top = elmnt.offsetTop - pos2 + 'px' + elmnt.style.left = elmnt.offsetLeft - pos1 + 'px' + } + function closeDragElement() { + // stop moving when mouse button is released: + document.onmouseup = null + document.onmousemove = null + } + } + /****************************************************/ + + /******************************** + * NODE & EDGE BUTTONS + ********************************/ + + // editButton pressed + $('#editButton').on('click', () => { + $('#context-help-text').html($('#ch_editMode').html()) + this.buttonMode = ConceptMap.editButton + this.activeButton(1) + this.network.disableEditMode() + this.network.unselectAll() + }) + // nodeButton pressed + $('#nodeButton').on('click', () => { + $('#context-help-text').html($('#ch_addNodeMode').html()) + this.buttonMode = ConceptMap.nodeButton + this.activeButton(2) + this.network.disableEditMode() + this.network.unselectAll() + }) + // edgeButton pressed + $('#edgeButton').on('click', () => { + $('#context-help-text').html($('#ch_addEdgeMode').html()) + this.buttonMode = ConceptMap.edgeButton + this.mode = ConceptMap.addEdge + this.activeButton(3) + this.network.unselectAll() + this.network.addEdgeMode() + }) + + // Default Infotext + $('#context-help-text').html($('#ch_editMode').html()) + + // ESC Pressed + document.addEventListener('keyup', e => { + if (e.code == 'Escape') { + hideForm() + } + }) + + this.network.on('hoverEdge', () => { + if (buttonMode == ConceptMap.edgeButton) { + return + } + $('#context-help-text').html($('#ch_hoveredge').html()) + this.network.canvas.body.container.style.cursor = 'pointer' + }) + this.network.on('hoverNode', () => { + if (this.mode === ConceptMap.none) { + $('#context-help-text').html($('#ch_hovernode').html()) + } + this.network.canvas.body.container.style.cursor = 'pointer' + }) + + /******************************** + * HOVER NODE/EDGE EVENT EXIT + ********************************/ + this.network.on('blurNode', () => { + // Show Infotext depending on current buttonMode + if (buttonMode == ConceptMap.editButton) + $('#context-help-text').html($('#ch_editMode').html()) + else if (buttonMode == ConceptMap.nodeButton) + $('#context-help-text').html($('#ch_addNodeMode').html()) + else if (buttonMode == ConceptMap.edgeButton) + $('#context-help-text').html($('#ch_addEdgeMode').html()) + this.network.canvas.body.container.style.cursor = 'default' + }) + this.network.on('blurEdge', () => { + // Show Infotext depending on current buttonMode + if (buttonMode == ConceptMap.editButton) + $('#context-help-text').html($('#ch_editMode').html()) + else if (buttonMode == ConceptMap.nodeButton) + $('#context-help-text').html($('#ch_addNodeMode').html()) + else if (buttonMode == ConceptMap.edgeButton) + $('#context-help-text').html($('#ch_addEdgeMode').html()) + this.network.canvas.body.container.style.cursor = 'default' + }) + + /*************************************** + * Add edge: highlight target node + ***************************************/ + this.network.on('controlNodeDragging', params => { + if (!params.controlEdge.to && this.hoveredNodeStyle) { + this.resetNodeStyle() + } + + if ( + params.controlEdge.to && + params.controlEdge.to !== params.controlEdge.from && + !this.hoveredNodeStyle + ) { + const targetNode = this.nodes.get(params.controlEdge.to) + this.hoveredNodeStyle = { id: targetNode.id, color: targetNode.color } + const currentColor = targetNode.color + targetNode.color = { + ...currentColor, + border: '#000000', + } + + this.nodes.update(targetNode) + } + }) + + /*************************************** + * DRAG EVENT: save new node position + ***************************************/ + this.network.on('dragStart', params => { + if (params.nodes.length > 0) { + // Does not fire when Stage is dragged + this.ids[0] = params.nodes[0] + this.mode = ConceptMap.dragNode + this.oldPointerX = params.pointer.canvas.x + this.oldPointerY = params.pointer.canvas.y + $('#edit-dialog').addClass('d-none') + } + }) + this.network.on('dragEnd', async params => { + const postObj = {} + + switch (this.mode) { + case ConceptMap.dragNode: + const newPointerX = params.pointer.canvas.x + const newPointerY = params.pointer.canvas.y + const diffX = this.oldPointerX - newPointerX + const diffY = this.oldPointerY - newPointerY + + const selectedNodes = this.network.getSelectedNodes() + + if (selectedNodes.length) { + const data = { + concepts_attributes: selectedNodes.map(nodeId => { + return { + id: nodeId, + x: this.nodes.get(nodeId).x - diffX, + y: this.nodes.get(nodeId).y - diffY, + lock: false, + } + }), + } + const res = await ajax({ url: this.conceptMapsPath.slice(0, -5), method: 'PUT', data }) + const body = await res.json() + if (body.concepts) { + this.nodes.update(body.concepts) + } + } + + this.ids = [] + this.hideForm() + } + }) + + /************************************ + * DOUBLECLICK EVENT: Create a Node + *************************************/ + this.network.on('doubleClick', params => { + if (this.buttonMode == ConceptMap.edgeButton) return + this.createNode(params) + }) + + /********************************************************** + * CLICK STAGE EVENT: If NodeButton active, create a Node + ***********************************************************/ + this.network.on('click', params => { + if (params.nodes.length == 0 && params.edges.length == 0) { + ///STAGE selected + if (this.buttonMode == ConceptMap.nodeButton) { + this.createNode(params) + } + } + }) + + /*************************************** + * SELECT EVENT: edit Node(s) or Edge + ****************************************/ + this.network.on('select', params => { + if (buttonMode == ConceptMap.editButton) { + if (params.nodes.length > 1) { + ///MULTI-NODES selected/// + this.mode = ConceptMap.editMultiNode + this.ids = params.nodes + this.showForm(this.nodes.get(this.ids[0]).x, this.nodes.get(this.ids[0]).y) + } else if (params.nodes.length === 1) { + ///NODE selected/// + this.editNode(params) + } else if (params.edges.length === 1) { + ///EDGE selected/// + this.editEdge(params) + } + } + }) + } + + /********************* + * HELPER FUNCTIONS + *********************/ + resetNodeStyle = () => { + const targetNode = this.nodes.get(this.hoveredNodeStyle.id) + targetNode.color = this.hoveredNodeStyle.color + this.nodes.update(targetNode) + this.hoveredNodeStyle = undefined + } + + /****************************************************** + * Highlights the active button (Edit/Add-Node/Add-Edge) + *******************************************************/ + activeButton = button => { + $('#editButton').removeClass('active-button') + $('#nodeButton').removeClass('active-button') + $('#edgeButton').removeClass('active-button') + if (button == 1) $('#editButton').addClass('active-button') + else if (button == 2) $('#nodeButton').addClass('active-button') + else if (button == 3) $('#edgeButton').addClass('active-button') + } + + /********************************* + * create node + ********************************/ + createNode = params => { + this.canvasX = params.pointer.canvas.x + this.canvasY = params.pointer.canvas.y + this.mode = ConceptMap.addNode + this.showForm() + } + + /********************************* + * edit node + ********************************/ + editNode = params => { + this.ids = params.nodes + this.canvasX = this.nodes.get(this.ids[0]).x + this.canvasY = this.nodes.get(this.ids[0]).y + this.mode = ConceptMap.editNode + this.showForm() + } + + /********************************* + * create edge + ********************************/ + createEdge = params => { + $('#start').val(params.from) + $('#end').val(params.to) + $('#context-help-text').html($('#ch_addEdgeMode').html()).removeClass('d-none') + const startNode = this.nodes.get(params.from) + const endNode = this.nodes.get(params.to) + this.canvasX = Math.min(startNode.x, endNode.x) + Math.abs(startNode.x - endNode.x) / 2 + this.canvasY = Math.min(startNode.y, endNode.y) + Math.abs(startNode.y - endNode.y) / 2 + this.mode = ConceptMap.addEdge + this.showForm() + } + + /********************************* + * edit edge + ********************************/ + editEdge = params => { + this.ids = params.edges + this.canvasX = params.pointer.canvas.x + this.canvasY = params.pointer.canvas.y + this.mode = ConceptMap.editEdge + this.showForm() + } + + /********************************* + * delete nodes or edges + ********************************/ + destroy = async () => { + switch (this.mode) { + case ConceptMap.editNode: + await ajax({ url: this.conceptsPath + '/' + this.ids[0], method: 'DELETE' }) + this.nodes.remove(this.ids[0]) + break + case ConceptMap.editEdge: + await ajax({ url: this.linksPath + '/' + this.ids[0], method: 'DELETE' }) + this.edges.remove(this.ids[0]) + break + case ConceptMap.editMultiNode: + for (let i = 0; i < this.ids.length; i++) { + await ajax({ url: this.conceptsPath + '/' + this.ids[i], method: 'DELETE' }) + this.nodes.remove(this.ids[i]) + } + break + } + this.mode = ConceptMap.none + this.ids = [] + this.hideForm() + } + + /********************************* + * validate inputs: check for duplicated node names + ********************************/ + validateForm = () => { + if (this.mode == ConceptMap.addNode || this.mode == ConceptMap.editNode) { + var t = $('#entry_concept').val() + const node = this.nodes.get({ + filter: function (item) { + return item.label.toLocaleLowerCase() === t.toLocaleLowerCase() + }, + }) + if (node == null || node.length == 0 || node[0].id == this.ids[0]) { + return true + } else { + // Node with this label already exists + this.network.focus(node[0].id) + // if context_help is displayed show doubleNodeToast instead for 6s then display context_help again + if (!$('#context-help').hasClass('d-none')) { + $('#context-help').addClass('d-none') + $('#doubleNodeToast').fadeIn(500) + setTimeout(function () { + $('#doubleNodeToast').fadeOut(500) + $('#context-help').removeClass('d-none') + }, 6000) + } else { + $('#doubleNodeToast').fadeIn(500) + setTimeout(function () { + $('#doubleNodeToast').fadeOut(500) + }, 6000) + } + return false + } + } else return true + } + + /********************************* + * focus element + ********************************/ + focus = id => { + setTimeout(function () { + $(id).focus() + }, 100) + } + + /********************************* + * put or post after editing/adding node(s) or an edge + ********************************/ + onSubmit = async () => { + const postObj = {} + let method = 'PUT' + let path + $('#entry_concept').val($('#entry_concept').val().replace(/\\/g, ' ')) // prevent input of "\" because vis network cannot handle this symbol + + switch (this.mode) { + case ConceptMap.addNode: + method = 'POST' + case ConceptMap.editNode: + postObj['x'] = $('#x').val() + postObj['y'] = $('#y').val() + postObj['label'] = $('#entry_concept').val() + postObj['shape'] = $('#shape').val() + postObj['color'] = $('#color').val() + path = this.conceptsPath + break + case ConceptMap.addEdge: + method = 'POST' + postObj['start_id'] = parseInt($('#start').val(), 10) + postObj['end_id'] = parseInt($('#end').val(), 10) + case ConceptMap.editEdge: + postObj['label'] = $('#entry_link').val() + path = this.linksPath + break + case ConceptMap.editMultiNode: + const selectedNodes = this.network.getSelectedNodes() + + if (selectedNodes.length) { + const data = { + concepts_attributes: selectedNodes.map(nodeId => { + return { id: nodeId, shape: $('#shape').val(), color: $('#color').val(), lock: false } + }), + } + const res = await ajax({ url: this.conceptMapsPath.slice(0, -5), method: 'PUT', data }) + const body = await res.json() + if (body.concepts) { + this.nodes.update(body.concepts) + } + } + + await this.hideForm(true) + this.ids = [] + return + default: + // Nothing to submit, really. Shouldn't happen, but you never know. + return + } + if (!postObj.label) { + alert('Der Name muss ausgefüllt sein!') + return + } + + //Fetch for all cases except MultiNodes + const res = await ajax({ + url: path + (method === 'PUT' ? '/' + this.ids[0] : ''), + method, + data: { ...postObj, lock: false }, + }) + + var body = await res.json() + if (body.edge) { + this.edges.update(body.edge) + } + if (body.node) { + this.nodes.update(body.node) + } + await this.hideForm(true) + this.ids = [] + } + + /********************************* + * colorpicker stuff + ********************************/ + selectColor = color => { + for (let i = 1; i <= 6; ++i) + if (this.standardizeColor($('#color' + i).css('background-color')) === color) + this.changeColor(i) + } + changeColor = id => { + const color = $('#colorSelect-' + id).css('background-color') + + $('#currentColor').css('background-color', color) + $('#color').attr('value', this.standardizeColor(color)) + + $('.colorSelectIcon').each(function () { + $(this).html('') + }) + + $('#colorSelect-' + id).html("") + $('#entry_concept').focus() + } + standardizeColor = color => { + const ctx = document.createElement('canvas').getContext('2d') + ctx.fillStyle = color + return ctx.fillStyle + } + + /********************************* + * shapepicker stuff + ********************************/ + selectShape(shape) { + for (let i = 1; i <= 3; ++i) if ($('#shape' + i).attr('value') == shape) changeShape(i) + } + changeShape(i) { + const shape = $('#shape' + i).attr('value') + $('#shape').attr('value', shape) + switch (shape) { + case 'circle': + $('#currentShape') + .addClass('currently-circle') + .removeClass('currently-box currently-ellipse') + break + case 'box': + $('#currentShape') + .addClass('currently-box') + .removeClass('currently-circle currently-ellipse') + break + case 'ellipse': + $('#currentShape') + .addClass('currently-ellipse') + .removeClass('currently-box currently-circle') + break + } + $('#entry_concept').focus() + $('#shapeSelect').css('display', 'none') + return false + } + + selectColor = color => { + for (var i = 0; i < 6; ++i) { + if (this.standardizeColor($('#colorSelect-' + i).css('background-color')) === color) { + this.changeColor(i) + } + } + } + + /****************************************** + * controls buttons to display in EditForm + *******************************************/ + initNodeInputs = () => { + $('#entry_concept').removeClass('d-none') + $('#entry_link').addClass('d-none') + $('#colorpicker').removeClass('d-none') + $('#shapepicker').removeClass('d-none') + $('#edgepicker').addClass('d-none') + this.mode === ConceptMap.addNode && $('#delete').addClass('d-none') + this.mode === ConceptMap.editNode && $('#delete').removeClass('d-none') + $('#x').attr('value', this.canvasX) + $('#y').attr('value', this.canvasY) + this.focus('#entry_concept') + } + + /****************************************** + * controls buttons to display in EditForm + *******************************************/ + initEdgeInputs = () => { + $('#entry_concept').addClass('d-none') + $('#entry_link').removeClass('d-none') + $('#colorpicker').addClass('d-none') + $('#shapepicker').addClass('d-none') + $('#edgepicker').removeClass('d-none') + this.mode === ConceptMap.addEdge && $('#delete').addClass('d-none') + this.mode === ConceptMap.editEdge && $('#delete').removeClass('d-none') + $('#x').attr('value', this.canvasX) + $('#y').attr('value', this.canvasY) + this.focus('#entry_link') + } + + /****************************************** + * controls buttons to display in EditForm + *******************************************/ + initMultiNodeInputs = () => { + $('#entry_concept').addClass('d-none') + $('#entry_link').addClass('d-none') + $('#colorpicker').removeClass('d-none') + $('#shapepicker').removeClass('d-none') + $('#edgepicker').addClass('d-none') + $('#delete').removeClass('d-none') + } + + /************************************* + * Calculates Position of EditForm + ************************************/ + setDialogPosition = () => { + $('#edit-dialog').removeClass('d-none') + const form_width = $('#edit-dialog').width() + const form_height = $('#edit-dialog').height() + + // mobile fix: detecting the screen size of the virtual keyboard does not + // seem to be possible, so we simply place the dialog at the top of the + // screen on smaller screens to ensure it is not obscured by the virtual keyboard. + if (window.innerWidth < 500) { + $('#edit-dialog').attr( + 'style', + 'z-index: 1; position:absolute;left:' + + (window.innerWidth / 2 - form_width / 2) + + 'px;top:' + + '100px;' + ) + return + } + + var x_pos = + $('#map-canvas').offset().left + + this.network.canvasToDOM({ x: this.canvasX, y: this.canvasY }).x + var y_pos = + $('#map-canvas').offset().top + + this.network.canvasToDOM({ x: this.canvasX, y: this.canvasY }).y + var left = x_pos - form_width / 2 // Form is in bounds + var top = y_pos - form_height / 2 // Form is in bounds + + // Form is out of bounds right side + if (form_width / 2 > window.innerWidth - x_pos) { + left = window.innerWidth - form_width - 20 + } + // Form is out of bounds left side + else if (x_pos - form_width / 2 < 0) { + left = 20 + } + //Form is out of bounds bottom + if (form_height > window.innerHeight - y_pos) { + top = window.innerHeight - form_height - 20 + } + //Form is out of bounds top + else if (y_pos - form_height / 2 < 0) { + top = 20 + } + + $('#edit-dialog').attr( + 'style', + 'z-index: 1; position:absolute;left:' + left + 'px;top:' + top + 'px;' + ) + } + + toggleNodeLock = async status => { + //TODO this currently only locks the first selected concept + //TODO for multiple selected nodes, we need a different endpoint to lock more than one node + //TODO same goes for toggleEdgeLock + //TODO see comment in onSubmit for editMultiNodes + const res = await ajax({ + url: this.conceptsPath + '/' + this.ids[0], + method: 'PUT', + data: { + concept: { + label: this.nodes.get(this.ids[0]).label, + lock: status, + x: this.nodes.get(this.ids[0]).x, + y: this.nodes.get(this.ids[0]).y, + }, + }, + }) + const body = await res.json() + this.nodes.update(body.node) + } + + toggleEdgeLock = async status => { + const res = await ajax({ + url: this.linksPath + '/' + this.ids[0], + method: 'PUT', + data: { + link: { + label: this.edges.get(this.ids[0]).label, + lock: status, + start_id: this.edges.get(this.ids[0]).from, + end_id: this.edges.get(this.ids[0]).to, + }, + }, + }) + const body = await res.json() + this.edges.update(body.edge) + } + + checkIfIsLocked = elements => { + const isLocked = this.ids.reduce((acc, id) => { + const l = elements.get(id).lock + // check for 'false' is necessary, because vis seems to occasionally transform false into 'false' + return acc || (l && l !== 'false') + }, false) + + return isLocked + } + + /******************************************* + * EditForm to Create/Edit Nodes and Edges + * Controls which buttons are displayed + *******************************************/ + showForm = async () => { + this.scale = this.network.getScale() + this.viewPosition = this.network.getViewPosition() + + let isLocked = false + + if (this.enableCoworking) { + switch (this.mode) { + case ConceptMap.editEdge: + isLocked = this.checkIfIsLocked(this.edges) + break + case ConceptMap.editNode: + case ConceptMap.editMultiNode: + isLocked = this.checkIfIsLocked(this.nodes) + break + } + + if (isLocked) { + alert(getLanguage() === 'de' ? 'Dieses Element ist gesperrt!' : 'This element is locked!') + return + } + + if (!this.enableCoworking) { + return + } + + switch (this.mode) { + case ConceptMap.editEdge: + await this.toggleEdgeLock(true) + break + case ConceptMap.editNode: + case ConceptMap.editMultiNode: + await this.toggleNodeLock(true) + break + } + } + + this.setDialogPosition() + + switch (this.mode) { + case ConceptMap.addNode: + $('#context-help-text').html($('#ch_newNode').html()) + $('#action').html(this.dialogTexts.addNode) + $('#entry_concept').val('') + this.initNodeInputs() //controls buttons to display + break + case ConceptMap.editNode: + $('#context-help-text').html($('#ch_editNode').html()) + $('#action').html(this.dialogTexts.editNode) + $('#entry_concept').val(this.nodes.get(this.ids[0]).label) + this.initNodeInputs() //controls buttons to display + this.selectColor(this.nodes.get(this.ids[0]).color.background) //determines current color + this.selectShape(this.nodes.get(this.ids[0]).shape) //determines current shape + break + case ConceptMap.editEdge: + $('#context-help-text').html($('#ch_editEdge').html()) + $('#action').html(this.dialogTexts.editEdge) + $('#entry_link').val(this.edges.get(this.ids[0]).label) + this.initEdgeInputs() //controls buttons to display + break + case ConceptMap.addEdge: + $('#context-help-text').html($('#ch_newEdge').html()) + $('#action').html(this.dialogTexts.addEdge) + $('#entry_link').val('') + this.initEdgeInputs() //controls buttons to display + break + case ConceptMap.editMultiNode: + $('#context-help-text').html($('#ch_edit').html()) + $('#action').html(this.dialogTexts.editNode) + this.initMultiNodeInputs() //controls buttons to display + this.selectColor(this.nodes.get(this.ids[0]).color.background) //determines current color + break + default: + console.error('Unknown mode: ', this.mode) + } + } + + /******************************************* + * Hides EditForm and sets EditMode active + *******************************************/ + hideForm = async skipUnlocking => { + $('#edit-dialog').addClass('d-none') + this.network.unselectAll() + $('#colorSelect').css('display', 'none') // close colorDropdown + $('#shapeSelect').css('display', 'none') // close shapeDropdown + $('#context-help-text').html($('#ch_editMode').html()) + + if (this.enableCoworking && !skipUnlocking) { + if (this.mode === ConceptMap.editNode || this.mode === ConceptMap.editMultiNode) { + await this.toggleNodeLock(false) + } else if (this.mode === ConceptMap.editEdge) { + await this.toggleEdgeLock(false) + } + } + + // if in edge mode, reactivate the mode on network (vis network resets the mode after edge creation) + if (this.mode === ConceptMap.addEdge) { + this.network.addEdgeMode() + } + + if (this.scale && this.viewPosition) { + this.network.moveTo({ scale: this.scale, position: this.viewPosition }) + this.scale = undefined + this.viewPosition = undefined + } + + if (this.hoveredNodeStyle) { + this.resetNodeStyle() + } + } + + /********************************* + * Send Concept Map Code to Mail + *********************************/ + sendMail = () => { + $('#emailgroup').removeClass('has-error') + $('#emailgroup').removeClass('has-success') + $('#submit').removeClass('btn-danger') + $('#submit').removeClass('btn-success') + if ($('#email').is(':valid') && $('#email').val() != '') { + $.ajax({ url: this.conceptMapsPath + '?email=' + $('#email').val() }) + $('#submit').addClass('btn-success') + $('#emailgroup').addClass('has-success') + } else { + $('#submit').addClass('btn-danger') + $('#emailgroup').addClass('has-error') + } + } + + /********************************************** + * Search for Nodes and move camera to result + **********************************************/ + searchConcept = searchTerm => { + if (searchTerm === '') { + $('#searchGroup').removeClass('has-error') + $('#searchGroup').removeClass('has-success') + return + } + const node = this.nodes.get({ + filter: node => { + return ( + node.label.slice(0, searchTerm.length).toLocaleLowerCase() === + searchTerm.toLocaleLowerCase() + ) + }, + }) + if (node && node.length > 0) { + this.network.focus(node[0].id, { + animation: { duration: 1500, easingFunction: 'easeInQuad' }, + }) + $('#searchGroup').removeClass('has-error') + $('#searchGroup').addClass('has-success') + } else { + $('#searchGroup').removeClass('has-success') + $('#searchGroup').addClass('has-error') + } + } + + /********************************* + * external setter for node/edge data and mode + ********************************/ + setNodeData = nodeData => { + this.nodes.update(nodeData) + + // workaround for a vis-network bug: in coworking mode, the edges were not properly + // refreshed after the nodes were updated by someone else + if (this.enableCoworking) { + this.network.once('afterDrawing', this.network.redraw) + } + } + + setEdgeData = edgeData => { + this.edges.update(edgeData) + } + + // DH Make the mode available for the channel + setMode = mode => { + this.mode = mode + } +} + +export default ConceptMap diff --git a/app/frontend/js/StudentLog.js b/app/frontend/js/StudentLog.js new file mode 100644 index 00000000..c62ccfe7 --- /dev/null +++ b/app/frontend/js/StudentLog.js @@ -0,0 +1,42 @@ +class StudentLog { + constructor() { + // DH: If the student wants to logout, make sure that the edit-dialog closes + $('#header-nav').on('click', () => { + if ($('#edit-dialog').is(':visible')) { + // If browser closed or reload, make sure to release the lock! + window.hideForm() + } + }) + + // DH: filter the student logs + // DH: Check if a change happened + $('#students').on('click', function () { + // Go through all the students + $('.filter_students:checkbox').each(function () { + if ($(this).is(':checked')) { + // Show all checked + $('*[data-student="' + this.id + '"]').show() + } else { + // Hide all unchecked + $('*[data-student="' + this.id + '"]').hide() + } + }) + }) + // DH: Check all filters + $('#checkAll').on('click', function () { + $('.filter_students').prop('checked', true) + $('#students').click() + }) + // DH: Uncheck all filters + $('#uncheckAll').on('click', function () { + $('.filter_students').prop('checked', false) + $('#students').click() + }) + // DH: filter students, who left the map + $('#label_filter_left_students').on('click', function () { + $('#students').click() + }) + } +} + +export default StudentLog diff --git a/app/frontend/js/helpers.js b/app/frontend/js/helpers.js new file mode 100644 index 00000000..bc1345e3 --- /dev/null +++ b/app/frontend/js/helpers.js @@ -0,0 +1,33 @@ +export const getLanguage = () => { + const language = + navigator.userLanguage || + (navigator.languages && navigator.languages.length && navigator.languages[0]) || + navigator.language || + navigator.browserLanguage || + navigator.systemLanguage + return language.substr(0, 2) +} + +export const ajax = async ({ + url, + method = 'GET', + data, + contentType = 'application/json', + accept = 'application/json', +}) => { + return await fetch(url, { + headers: { + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRF-Token': getCSRFToken(), + 'Content-Type': contentType, + Accept: accept, + }, + mode: 'same-origin', + method: method, + body: contentType === 'application/json' ? JSON.stringify(data) : data, + }) +} + +export const getCSRFToken = () => { + return document.getElementsByName('csrf-token')[0].getAttribute('content') +} diff --git a/app/frontend/scss/application.scss b/app/frontend/scss/application.scss new file mode 100644 index 00000000..9b827e32 --- /dev/null +++ b/app/frontend/scss/application.scss @@ -0,0 +1,12 @@ +@import '../scss/base64.scss'; + +@import 'bootstrap-icons/font/bootstrap-icons.css'; +@import 'vis-network/styles/vis-network.min.css'; +@import 'vis-timeline/styles/vis-timeline-graph2d.min.css'; + +@import './variables.scss'; +@import 'bootstrap/scss/bootstrap.scss'; +@import './frontend_editor.scss'; +@import './student-log.scss'; +@import './navigation-elements.scss'; +@import './bootstrap-overrides.scss'; diff --git a/app/frontend/scss/base64.scss b/app/frontend/scss/base64.scss new file mode 100644 index 00000000..9b039549 --- /dev/null +++ b/app/frontend/scss/base64.scss @@ -0,0 +1,11 @@ +$circle-icon: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAABmJLR0QA/wD/AP+gvaeTAAABwUlEQVRIid2WzS4EQRSFP2JWLJAM8beRWPAEBm/AAyAZ40Emw7Mg4gXGYvAMfhZ+RqwEiUhYY6Yt6t5Ud6d7ukr3ak7SqU71qXOqqu+9VdDvGPDgTgAbwDwwI30vwBPQBN6LmtQacAF0gCDl+RXOSh6jEeAkJvwNXGNW1JT37xjnWMZ6YVbEVOQOqAKjCdxRYAe4D/GvRMN5ZWrWAerAkMO4IaABdGXsJTDsYqjb2AE2XWcZwlbI9CiLvIbdlvo/zBR7otEFKr2IF9h/5rKNaSgBD6J1lkaaxIZ+NYeZooZNmXISYRcb+knR6IsxbMrUtHMwRFiQ9hb4KsDwE/NrwtoRw2lpXwswU6jWVJJhIK1Pfc2Caql2xPAtPpsCoLum2hHDR2mXKCZoxoHFmHYEE5gQDjC1MS806lPTAuBcSPfkT/y2aLV6EVexpa2Rw3AfW9qWs8jH2OK99Q+zbWzxPnAZMII5z3SGe5gtykIJszLv4wnM4ammAaYQ1zDlKo5xTIC0Q/xL7L3HGcOY80xnHAA/wA1wKs+N9On3LnDos7IkVDBHjKZM2iWqhUOA+JSxMrCOuSbOSd8z9pr44aHVx/gDSIaOZCgnJ2gAAAAASUVORK5CYII='); + +$shapes: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeCAYAAAA7MK6iAAAABmJLR0QA/wD/AP+gvaeTAAAETklEQVRIicWWXUwcVRTH//fOzM7OzLI7+wHdtTXQSpdQKoVGQaxV2m7FaqqNplZDjGKbNhS1xjTxRWNfTdRG7YOmDX0w6kOjNjHSqME0JCamGCXyoaUURIqwy8cuu+z3fPigICzs7rCY+E8mmcy99/zOuWfuPQf4n0SMTPI2Hnfd4ebeYql2gGXBsiyTjMYRSyh6vz+UeqP/2/M9/znY90TrcUnUzz5aA7Hpbhk2C7c4dmtaxfsd0cDAWLrjKp06hkuXVKNgJtdg05HWl0ud+rsv+Ah/f60TopldNm4VKfbv4CVZpBVTN7iG0V+7P1032Otr3VLqwBctuzWuyuuEicvu49bbWC6egjsi1UbGfuu+ZgRMs4KL8dFjtQrvKRbB54Au6Lm9YpGFJ6dx+HD+ydnAjY1nWI7V73TbdLjsghE7YCiwp5q3eoP2hoLBk5jc5JbBUwoIGXnNpZoyzi6bmbqCwTpDrGYWlBBDp21RkkAgcNRZMJhD2h+KElVVdWi6cXAgpGnhuNpbMLiv84I/EMG8rgPh+aRhcOcvSX88gR8KBgNAWkN7zxjV/dNRQ9DAnIYb48qIG+5b6wIPh9nXO/uZqeGJFGZD8ZxGVA04fXFuKhTSKwRxdmSXr+3JfOCsZ2526JrKbKz57I8A92wRlzB7HCzM/Mo/PBzT8Ep72D8wqgjnvR65ucRmvRKM3MNu2H5hZuzn1JrBADBz86eQ7mhonwjre7oHU654IkklniGqTnBzUsUnXfHg2S+jI8MTyjv77JZdjzgsEiWAx8RJfcmUfXSouyObbQKAVB958U1C6EHRab5e0VL9MQD0netpVmPpkoWJ6ZjqVmLpzUsXU47O8DbToDmslF3c6vHI7L+ZOzk0+WdfNFk3+N0H46uCKw+0HpJKuc/tO0Ti3lkJyv69CVpag57Wcm0IAGCudxoN30/PP19isyz9PhRP6a+NTH119etzB1dbR0083c47CTFZhUXoP9GAEdmcD+UZJLsm0s3FVkum4XLBRMpFrr56b9vOVcELL5HrMSjRdN4IlyrYNY6nBDP4LDfcqY3OYruJfJgTnAoqUBKKYagaV6H9OKU/JC/pDDJUzDG4zyqU37W/7eGs4LVq9sooThRJeQ0cdcuylSFvZ5bLgsCpUBK23yOoswh5q4jIUBxyWW6/d27DiVXBOgGMVqPg5RG8VFRk2NHHXVZJYsir1Q8+I60A2yolmGQ+r5H4eBRlc2lsFrKmdoVYQnDMbfPYqO3MCnB4IIpkKH8lmrs8jLaVpyevHpAlzsHQp6saT7qXga3bJPC23BGH+2dQr1IUc8a7kqU6tcnpcYn0vWVgzsqAMLlzHPtmDC2y8dxmaptooqUct9vra91CFQVBLQVoeVoNJaGgBBQCXVs7lKl9NtFlJlo9qWo66mCLxF5GojLDmsKEkqy3SHR03uOV+LBZ1Yx1BxlSAcyk1UAgovoK9ny9+gtlSW+Sm7i9DwAAAABJRU5ErkJggg=='); + +$box-icon: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeCAYAAAA7MK6iAAAABmJLR0QA/wD/AP+gvaeTAAAA7UlEQVRIie3WvUpDQRDF8V9iqdgYIWKnPoEICqKIjyQWPoRf+EIW2sTkGdTExlbELhILVxjCDRdulhSyBxbO2R3mP90sRUUL1DpuMMQYk4ZnjFdco1MH3cHbHLBZZ4TtCGoF30Yfuyk/oIfvumlnaAn7OEq5n/JkuvAkTHjbEFalu9D3uKrgPBR0M4K7oe/Z32U7FKwE/54RHHutVoEXqgIu4AL+X+DP4DcyMjaD/6gCD4K/yARtTfUaxIc4RA97KT/iHl8Nocs4xWHKTzhQsRb5XdYj+T8CQ2zVTdrBFV7M//V5xiXW6qBFRdn0A65/bxj4UOtqAAAAAElFTkSuQmCC'); + +$ellipse-icon: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeCAYAAAA7MK6iAAAABmJLR0QA/wD/AP+gvaeTAAABwElEQVRIie3Wu24TQRQG4M8uoCEIxQk1tEDNRYkIlwaegCgKQvACCF4ABSi4vBQECmgiCiIBXQpDQAIJKEwuxBQza09Wu2SXeBvkXzqSNXPO/5/xnD1nGON/R6uG7xGcxQlMRoNv0VbxCt9Hkdg07uINdtDfw35H3zuY+hfBCTzFRonAJtajbZb4bOBJ5KqEOXRzJO+whCvoFMR04t796JvGdiPnX7GYO+UyZqtmnOA8XiQ8v7BQ5nwZ29Gxh5vqFV8ercjRi5zbuJh3OircVx8/cG4fgnnM4Gfk/iQU7AAPDf+WxRGKZriW8C9liwfxJS4+a0A0w3LUWMcBQlPIsrneoPCNROd0G8eSzbcNCqfcx9sxgwz7qeI62GljLVk42aDYqeT3GuGiPwsnf96gcNZQBsVFaHVNFth8wn8v3ZgWPu6+8LHPjFB01rCBfFQwtS4Zfcu8JfTprGVeKHNeSBz7wr3M1UygFQVe2j0k5vcKLBqL74U6uKp4wE/FvQf4kIvtCtOqEibw2O7Tp7Zl+BDYKvHp4REOVRVN0cFtrAjPmipPn5UYU/RgGKDO3R3GGaERTCbEX6Ot4rUwVscYwx9GWanUbVsZygAAAABJRU5ErkJggg=='); + +$arrow: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeCAYAAAA7MK6iAAAABmJLR0QA/wD/AP+gvaeTAAABVklEQVRIieXVv05UQRTH8Q/SYJYQsVgqd8UK6awsoPANCJX0ml3DE9BoIpWPYHgFKhJ7Ev5VVAYSKIjaQQGJVhSrl2LmwuxmY9gsO8T1l0zuvTlzzncy98z8+N80OqC6D/EBj3A0IEZXLaHAHzRzgqv4lsAbOeE1nCTw5TQ4krzPx+eO8I8WsIkzPI/xdP5t9BQrMa/AO6x1ru43WniC1TjxS4yfxu9+R1nfg1j4HIdxXGAfv7AX49sxsV/9iPUHrrfCThYROp0D+uY+oHNCJxfCsarngJbglnCcskFL1VDJDf03VRG2K6vqQmO0hEbJBk2dJQv4r44yVNBKAu13rMea7/FTsNdxHOBrZF2702N3d8OUPfESE3iBScxgNrLajL2Bz25M+xO+9wgtsIVjTOEVNnCZLGi3W2KnozzrEdyXGtqdpZoT3kzgr3OCYREfMZYbPJy6AjMcdS9NPz6cAAAAAElFTkSuQmCC'); + +$pencil-light: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAABmJLR0QA/wD/AP+gvaeTAAAAlUlEQVQ4je3UPQ4BURQF4C+0JIZWI1FpaKcm/hILVIudCInEEmyBJYxirkY05k0h4XSn+XJu8R7paWNYgwMmuKPAPhWbYYRdgAUGVbE1jsjQDPSGVgrWjZ5hrDz/46xesG70aRVsgRN60Ts4YPPHfh2bfy2We/8CllUw2OISUNKyZ67K7+ecugz6gV1jaZ6CQSPQWvIAivIsamqcc1wAAAAASUVORK5CYII='); diff --git a/app/frontend/scss/bootstrap-overrides.scss b/app/frontend/scss/bootstrap-overrides.scss new file mode 100644 index 00000000..a0d7140a --- /dev/null +++ b/app/frontend/scss/bootstrap-overrides.scss @@ -0,0 +1,37 @@ +.dropdown-toggle { + &:after { + display: none; + } +} + +.dropdown-menu, +.modal-dialog { + box-shadow: 0 0.2em 0.8em rgba(0, 0, 0, 0.175); +} + +.popover-header { + display: flex; + justify-content: space-between; + .close { + span:before { + font-size: 1em !important; + } + } +} + +#menuButton span:before { + font-weight: bold !important; +} + +.card-header a { + text-decoration: none !important; +} + +tbody a { + color: black !important; + text-decoration: none !important; +} + +.btn-group { + display: block; +} diff --git a/app/frontend/scss/frontend_editor.scss b/app/frontend/scss/frontend_editor.scss new file mode 100644 index 00000000..070e2ca5 --- /dev/null +++ b/app/frontend/scss/frontend_editor.scss @@ -0,0 +1,368 @@ +html, +body { + height: 100%; + min-height: 100%; + margin: 0px; + padding: 0px; + touch-action: none; + .main-container { + top: $header-height; + margin: 0 !important; + } +} + +.code-text { + display: none; +} + +@include media-breakpoint-up(sm) { + .button-text { + display: none; + } + + .code-text { + display: inline-block; + } +} + +@include media-breakpoint-up(md) { + .button-text { + display: block; + } + + .code-text { + display: inline-block; + } +} + +.color { + font-family: 'bootstrap-icons'; + &:before { + content: '\F4B1'; + } +} + +#currentShape { + background-repeat: no-repeat; + background-position: center; + &.currently-circle { + background-image: $circle-icon; + } + &.currently-box { + background-image: $box-icon; + } + &.currently-ellipse { + background-image: $ellipse-icon; + } +} + +.shape-select { + background-repeat: no-repeat; + background-position: center; + &.ellipse-select { + background-image: $ellipse-icon !important; + } + + &.circle-select { + background-image: $circle-icon !important; + } + + &.box-select { + background-image: $box-icon !important; + } +} + +.arrow { + background-image: $arrow !important; + background-position: 8px 1px; +} + +/* Blue */ +.info { + color: dodgerblue; +} + +.info:hover { + background: #2196f3; + color: rgb(0, 0, 0); +} + +.info:focus { + outline: none; + box-shadow: none; +} + +.active-button { + border: 3px solid rgb(0, 142, 185) !important; +} + +.bi-check-lg { + position: relative !important; + padding: 12 12 !important; +} + +#hoverButton { + background-image: $pencil-light; + background-position: 2px 1px; + background-repeat: no-repeat; + background-color: #fcfcfc; + border: 1px solid rgb(0, 0, 0); + border-radius: 15px; + box-sizing: content-box; + float: left; + font-family: verdana; + font-size: 12px; + height: 24px; + margin-left: 10px; + padding: 0 12px; + position: absolute; + z-index: 1; + display: block; +} + +#hoverButton:hover { + background-image: $pencil-light; + background-color: #aaa9a9; +} + +#login { + position: relative; + margin-top: 50px; + margin-left: 150px; + margin-right: 150px; +} + +.map-wrapper { + padding: 0 !important; +} + +#map-canvas { + height: calc(100% - $header-height); + background-color: #f8f8f8; +} + +#context-help { + position: absolute; + right: 50%; + bottom: 15px; + z-index: 1; + display: inline-block; + transform: translatex(50%); + background-color: #1b809e; + padding: 5px; + color: white; + padding: 10px; + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); +} + +#task { + position: absolute; + right: 50%; + top: 75px; + z-index: 1; + display: inline-block; + transform: translatex(50%); + background-color: white; + padding: 5px; + color: black; + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); + padding: 10px; +} + +.vis-button { + color: #1b809e; + font-family: 'bootstrap-icons'; + font-size: 32px; + cursor: pointer; + &:hover { + box-shadow: none !important; + color: #07668a; + } +} + +.vis-right { + background-image: none !important; + margin-left: 15px; + margin-bottom: 15px; + &:after { + content: '\f133'; + } +} + +.vis-left { + background-image: none !important; + margin-left: 15px; + margin-bottom: 15px; + &:after { + content: '\f129'; + } +} + +.vis-up { + background-image: none !important; + margin-left: 15px; + margin-bottom: 15px; + &:after { + content: '\f139'; + } +} + +.vis-down { + background-image: none !important; + margin-left: 15px; + margin-bottom: 15px; + &:after { + content: '\f118'; + } +} + +.vis-zoomIn { + background-image: none !important; + margin-right: 15px; + margin-bottom: 15px; + &:after { + content: '\f4f9'; + } +} + +.vis-zoomOut { + background-image: none !important; + margin-right: 15px; + margin-bottom: 15px; + &:after { + content: '\f2e5'; + } +} + +.vis-zoomExtends { + background-image: none !important; + margin-right: 15px; + margin-bottom: 15px; + &:after { + content: '\f2dd'; + } +} + +/* The snackbar - position it at the bottom and in the middle of the screen */ +.toast { + position: absolute; + right: 50%; + bottom: 15px; + z-index: 1; + display: inline-block; + transform: translatex(50%); + background-color: #1b809e; + padding: 5px; + color: white; + padding: 10px; + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); +} + +.error_toast { + background-color: rgb(214, 16, 16) !important; +} + +/* Animations to fade the snackbar in and out */ +@-webkit-keyframes fadein { + from { + bottom: 0; + opacity: 0; + } + to { + bottom: 30px; + opacity: 1; + } +} + +@keyframes fadein { + from { + bottom: 0; + opacity: 0; + } + to { + bottom: 30px; + opacity: 1; + } +} + +@-webkit-keyframes fadeout { + from { + bottom: 30px; + opacity: 1; + } + to { + bottom: 0; + opacity: 0; + } +} + +@keyframes fadeout { + from { + bottom: 30px; + opacity: 1; + } + to { + bottom: 0; + opacity: 0; + } +} + +#edit-dialog-header { + cursor: move; +} + +.editor-buttons { + z-index: 5; + margin: 1em; + right: 0; + .btn.editor-button { + font-family: 'bootstrap-icons'; + background-color: white; + border: 1px solid #ccc; + border-radius: 6px !important; + box-sizing: content-box; + height: 2em; + margin-bottom: 0.5em; + font-size: 1em; + padding: 0 12px; + z-index: 1; + color: $blue; + text-align: left; + flex-grow: 1; + @media (max-width: 768px) { + width: 1em; + span.button-text { + display: none; + } + } + &:before { + position: relative; + } + span.button-text { + font-family: $font-family-sans-serif; + padding-left: 1em; + } + } + #editButton { + &:before { + content: '\F4C9'; + } + } + + #nodeButton { + &:before { + content: '\F24D'; + } + } + + #edgeButton { + &:before { + content: '\F123'; + } + } + #logButton { + &:before { + content: '\F227'; + } + } +} diff --git a/app/frontend/scss/navigation-elements.scss b/app/frontend/scss/navigation-elements.scss new file mode 100644 index 00000000..168865ff --- /dev/null +++ b/app/frontend/scss/navigation-elements.scss @@ -0,0 +1,50 @@ +.navigation-elements { + height: $header-height; + .canvasButton { + color: #1b809e; + font-family: 'bootstrap-icons'; + font-size: 32px; + &:hover { + color: #07668a; + cursor: pointer; + } + } + + .main-menu { + &:before { + content: '\F479'; + font-weight: bold; + } + } + + .search { + &:before { + content: '\F52A'; + font-weight: bold; + } + } + + #header-nav { + display: block; + } + + #header-code { + background-color: #1b809e; + padding: 5px; + color: white; + margin: 1em !important; + } + + #header-search { + z-index: 1; + display: block; + } + + #submit-mail { + background-color: #1b809e; + + &:hover { + background-color: #07668a; + } + } +} diff --git a/app/frontend/scss/student-log.scss b/app/frontend/scss/student-log.scss new file mode 100644 index 00000000..33268413 --- /dev/null +++ b/app/frontend/scss/student-log.scss @@ -0,0 +1,6 @@ +#student-log { + .log-title { + font-size: 1.2em; + font-weight: bold; + } +} diff --git a/app/frontend/scss/variables.scss b/app/frontend/scss/variables.scss new file mode 100644 index 00000000..6fa8e39d --- /dev/null +++ b/app/frontend/scss/variables.scss @@ -0,0 +1,4 @@ +$header-height: 4em; + +$blue: #1b809e; +$primary: #337ab7; diff --git a/app/helpers/concepts_helper.rb b/app/helpers/concepts_helper.rb index 8b062535..252149b6 100644 --- a/app/helpers/concepts_helper.rb +++ b/app/helpers/concepts_helper.rb @@ -8,6 +8,10 @@ def colors ['#cfe0c8', '#0ea5c6', '#ffcdab', '#becbff', '#ded473', '#ff8484'] end + def shapes + [ 'ellipse', 'box', 'circle'] + end + def default_color return colors[0] end @@ -16,6 +20,10 @@ def palette(i) return colors[i] end + def shape_palette(i) + return shapes[i] + end + def get_highlight(color) i = colors.index(color) if i.nil? diff --git a/app/javascript/ConceptMap.js b/app/javascript/ConceptMap.js new file mode 100644 index 00000000..f6e5b92a --- /dev/null +++ b/app/javascript/ConceptMap.js @@ -0,0 +1,675 @@ +import $ from "jquery" +import vis from "vis-network" +import { DataSet } from "vis-data/peer/umd/vis-data.js" + +class ConceptMap { + static none = 0 + static addNode = 1 + static editNode = 2 + static addEdge = 3 + static editEdge = 4 + static dragNode = 5 + + constructor({ edgeData, nodeData, conceptsPath, conceptMapsPath, linksPath, dialogTexts }) { + this.conceptsPath = conceptsPath + this.conceptMapsPath = conceptMapsPath + this.linksPath = linksPath + this.dialogTexts = dialogTexts + + this.edges = new DataSet(edgeData) + this.nodes = new DataSet(nodeData) + + + this.container = $('#map-canvas')[0] + + this.mode = ConceptMap.none + this.canvasX = 0 + this.canvasY = 0 + this.id = 0 + + this.options = { + autoResize: true, + height: '100%', + width: '100%', + edges: { + arrows: { + to: { + enabled: true, + scaleFactor: 0.75 + }, + }, + smooth: false + }, + physics: { + enabled: false + }, + interaction: { + hover: true, + navigationButtons: true, + selectConnectedEdges: false, + hoverConnectedEdges: false, + } + + } + + this.network = new vis.Network( + this.container, + { + nodes: this.nodes, + edges: this.edges + }, + this.options) + + $('#context-help-text').html($('#ch_normal').html()) + + // close dialog on escape + $("#entry_concept").on('keyup', function (e) { + if (e.keyCode == 27) + hideForm() + }) + + // close dialog on escape + $("#entry_link").on('keyup', function (e) { + if (e.keyCode == 27) + hideForm() + }) + + this.network.on("hoverNode", () => { + if (this.mode == ConceptMap.none) { + $('#context-help-text').html($('#ch_hovernode').html()) + } + this.network.canvas.body.container.style.cursor = 'pointer' + }) + this.network.on("hoverEdge", () => { + if (this.mode == ConceptMap.none) { + $('#context-help-text').html($('#ch_hoveredge').html()) + } + this.network.canvas.body.container.style.cursor = 'pointer' + }) + + this.network.on("blurNode", () => { + if (this.mode == ConceptMap.none) { + $('#context-help-text').html($('#ch_normal').html()) + } + this.network.canvas.body.container.style.cursor = 'default' + }) + this.network.on("blurEdge", () => { + if (this.mode == ConceptMap.none) { + $('#context-help-text').html($('#ch_normal').html()) + } + this.network.canvas.body.container.style.cursor = 'default' + }) + + + /********************************* + * Network drag: save new node position + ********************************/ + this.network.on("dragStart", (params) => { + + if (params.nodes.length > 0) { + // DH: If a node is locked and the user wants to drag another node, make sure to close the Form + if(this.mode == ConceptMap.editNode) { + this.hideForm() + } + this.id = params.nodes[0] + this.mode = ConceptMap.dragNode + } + }) + + this.network.on("dragEnd", async (params) => { + console.log(this.mode) + switch (this.mode) { + case ConceptMap.dragNode: + + if(!this.nodes.get(this.id).lock || this.nodes.get(this.id).lock == "false"){ + const res = await fetch(this.conceptsPath + "/" + this.id, { + "method": "put", + "mode": "same-origin", + "headers": { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRF-Token': $('meta[name="csrf-token"]').attr('content') + }, + "body": JSON.stringify({ "concept": { 'label': this.nodes.get(this.id).label, 'x': params.pointer.canvas.x, 'y': params.pointer.canvas.y } }) + }) + const body = await res.json() + + this.nodes.update(body.node) + this.mode = ConceptMap.none + this.id = undefined + this.hideForm() + } + else{ + // DH: locked nodes should not be movable + this.nodes.update(this.nodes.get(this.id)) + this.mode = ConceptMap.none + this.id = undefined + + + } + case ConceptMap.none: + // DH: If the user changes the position of the node while editing another node, the node should not be moved! + this.nodes.update(this.nodes.get(this.id)) + this.mode = ConceptMap.none + this.id = undefined + } + }) + + /********************************* + * Network release: edit node or edge + ********************************/ + this.network.on("release", (params) => { + if (this.mode === ConceptMap.none) { + if (params.edges.length) { + this.editEdge(params) + } else if (params.nodes.length) { + this.editNode(params) + } + + + } + }) + + /********************************* + * Network click: create an edge if a node was previously held, or cancel edge creation + ********************************/ + this.network.on("click", params => { + if (this.mode === ConceptMap.addEdge && params.nodes.length) { + this.createEdge(params) + } else if (this.mode === ConceptMap.addEdge && !params.nodes.length) { + this.mode = ConceptMap.none + this.id = undefined + } + }) + + /********************************* + * Network hold: start edge creation or create a node + ********************************/ + this.network.on("hold", (params) => { + if (this.mode == ConceptMap.none) { + if (params.nodes.length > 0) { + this.id = params.nodes[0] + this.mode = ConceptMap.addEdge + } else { + this.createNode(params) + } + } + }) + } // END CONSTRUCTOR + + /********************************* + * create node + ********************************/ + createNode = (params) => { + const canvasX = params.pointer.canvas.x + const canvasY = params.pointer.canvas.y + this.mode = ConceptMap.addNode + this.showForm(canvasX, canvasY) + } + + /********************************* + * edit node + ********************************/ + editNode = async (params) => { + // DH: Inform the channel that this node is getting edit + this.id = params.nodes[0] + // DH: IMPORT: We have to compare both: the boolean value and the string value + if(!this.nodes.get(this.id).lock || this.nodes.get(this.id).lock == "false"){ + const res = await fetch(this.conceptsPath + "/" + this.id, { + "method": "put", + "mode": "same-origin", + "headers": { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRF-Token': $('meta[name="csrf-token"]').attr('content') + }, + "body": JSON.stringify({ "concept": { 'label': this.nodes.get(this.id).label, 'lock': 'true', 'x': this.nodes.get(this.id).x, 'y': this.nodes.get(this.id).y } }) + }) + const body = await res.json() + + this.nodes.update(body.node) + this.mode = ConceptMap.none + + let canvasX + let canvasY + + const currentNode = this.nodes.get(this.id) + if (currentNode && currentNode.label !== "") { + canvasX = currentNode.x + canvasY = currentNode.y + this.mode = ConceptMap.editNode + this.showForm(canvasX, canvasY) + } + }else{ + // DH: Message for the user that the node is locked! + let language = navigator.userLanguage || (navigator.languages && navigator.languages.length && navigator.languages[0]) || navigator.language || navigator.browserLanguage || navigator.systemLanguage + if(language == "de"){ + alert("Der Knoten ist gesperrt!") + }else{ + alert("The node is Locked!") + } + + } + + + } + + /********************************* + * create edge + ********************************/ + createEdge = (params) => { + if (params.nodes.length > 0) { + $('#start').val(this.id) + $('#end').val(params.nodes[0]) + $('#context-help-text').html($('#ch_addedge').html()).removeClass("d-none") + const startNode = this.nodes.get(this.id) + const endNode = this.nodes.get(params.nodes[0]) + const canvasX = Math.min(startNode.x, endNode.x) + Math.abs(startNode.x - endNode.x) / 2 + const canvasY = Math.min(startNode.y, endNode.y) + Math.abs(startNode.y - endNode.y) / 2 + + this.showForm(canvasX, canvasY) + } + else { + this.hideForm() + } + } + + /********************************* + * edit edge + ********************************/ + editEdge = async (params) => { + // DH: Inform the channel that this edge is getting edit + this.id = params.edges[0] + + // DH: Fill the edit-dialog, needed for the case that a node got deleted while a link is locked + $('#start').val(this.edges.get(this.id).from) + $('#end').val(this.edges.get(this.id).to) + + // DH: IMPORT: We have to compare both: the boolean value and the string value..... + if(!this.edges.get(this.id).lock || this.edges.get(this.id).lock == "false"){ + const res = await fetch(this.linksPath + "/" + this.id, { + "method": "put", + "mode": "same-origin", + "headers": { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRF-Token': $('meta[name="csrf-token"]').attr('content') + }, + "body": JSON.stringify({ "link": { 'label': this.edges.get(this.id).label, 'lock': 'true', "start_id": this.edges.get(this.id).from, "end_id": this.edges.get(this.id).to } }) + }) + const body = await res.json() + + this.edges.update(body.edge) + this.mode = ConceptMap.none + + if (params.edges.length > 0) { + this.id = params.edges[0] + const canvasX = params.pointer.canvas.x + const canvasY = params.pointer.canvas.y + this.mode = ConceptMap.editEdge + this.showForm(canvasX, canvasY) + } + }else{ + let language = navigator.userLanguage || (navigator.languages && navigator.languages.length && navigator.languages[0]) || navigator.language || navigator.browserLanguage || navigator.systemLanguage + if(language == "de"){ + alert("Die Verbindung ist gesperrt!") + }else{ + alert("The edge is Locked!") + } + } + + } + + /********************************* + * delete nodes or edges + ********************************/ + destroy = async () => { + switch (this.mode) { + case ConceptMap.editNode: + await fetch(this.conceptsPath + "/" + this.id, { + "method": "delete", + "mode": "same-origin", + "headers": { + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRF-Token': $('meta[name="csrf-token"]').attr('content') + } + }) + this.nodes.remove(this.id) + + this.mode = ConceptMap.none + this.id = undefined + + break + case ConceptMap.editEdge: + await fetch(this.linksPath + "/" + this.id, { + "method": "delete", + "mode": "same-origin", + "headers": { + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRF-Token': $('meta[name="csrf-token"]').attr('content') + } + }) + this.edges.remove(this.id) + + this.mode = ConceptMap.none + this.id = undefined + break + } + this.hideForm() + } + + /********************************* + * validate inputs: check for duplicated node names + ********************************/ + validateForm = () => { + if (this.mode == ConceptMap.addNode || this.mode == ConceptMap.editNode) { + var t = $('#entry_concept').val() + + const node = this.nodes.get({ + filter: function (item) { + return (item.label.toLocaleLowerCase() === t.toLocaleLowerCase()) + } + }) + if (node == null || node.length == 0 || node[0].id == this.id) + return true + else { + this.network.focus(node[0].id) + return false + } + } else { + return true + } + + } + + /********************************* + * focus element + ********************************/ + focus = (id) => { + setTimeout(function () { + $(id).focus() + }, 100) + } + + /********************************* + * put or post after editing/adding a node or an edge + ********************************/ + onSubmit = async () => { + const postObj = {} + let method = "put" + let path + + switch (this.mode) { + + case ConceptMap.addNode: + method = "post" + // DH: needed for the lock, set the lock to false + $("#lock").val("false") + + case ConceptMap.editNode: + postObj["x"] = $("#x").val() + postObj["y"] = $("#y").val() + postObj["label"] = $("#entry_concept").val() + postObj["color"] = $("#color").val() + // DH: add the ID + //postObj["id"] = $("#concept_id").val() + postObj["lock"] = $("#lock").val() + path = this.conceptsPath + break + case ConceptMap.addEdge: + method = "post" + // DH: needed for the lock, set the lock to false + $("#link_lock").val("false") + postObj["start_id"] = parseInt($("#start").val(), 10) + postObj["end_id"] = parseInt($("#end").val(), 10) + case ConceptMap.editEdge: + postObj["label"] = $("#entry_link").val() + // DH: Add the link id + //postObj["id"] = $("#link_id").val() + postObj["lock"] = $("#link_lock").val() + path = this.linksPath + break + default: + // Nothing to submit, really. Shouldn't happen, but you never know. + return + } + + const res = await fetch(path + (method === "put" ? "/" + this.id : ""), { + "method": method, + "mode": "same-origin", + "headers": { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRF-Token': $('meta[name="csrf-token"]').attr('content') + }, + "body": JSON.stringify(postObj) + + }) + + const body = await res.json() + + if (body.edge) { + this.edges.update(body.edge) + + } + if (body.node) { + this.nodes.update(body.node) + } + + this.hideForm() + } + + + /********************************* + * color picker stuff + ********************************/ + standardizeColor = (color) => { + var ctx = document.createElement('canvas').getContext('2d') + ctx.fillStyle = color + return ctx.fillStyle + } + + changeColor = (id) => { + var color = $('#color' + id).css('background-color') + $('#currentColor').css('background-color', color) + $("#color").attr("value", this.standardizeColor(color)) + for (var i = 1; i <= 6; ++i) { + $('#color' + i).html("") + } + $('#color' + id).html("") + $("#entry_concept").focus() + $("#colorSelect").css("display", "none") + } + + selectColor = (color) => { + for (var i = 1; i <= 6; ++i) { + if (this.standardizeColor($('#color' + i).css('background-color')) === color) { + this.changeColor(i) + } + } + } + + + initNodeInputs = (canvasX, canvasY) => { + $("#entry_concept").removeClass("d-none") + $("#colorpicker").removeClass("d-none") + $("#entry_link").addClass("d-none") + this.mode === ConceptMap.addNode && $("#delete").addClass("d-none") + this.mode === ConceptMap.editNode && $("#delete").removeClass("d-none") + $("#x").attr("value", canvasX) + $("#y").attr("value", canvasY) + this.focus("#entry_concept") + } + + initEdgeInputs = (canvasX, canvasY) => { + $("#entry_link").removeClass("d-none") + $("#entry_concept").addClass("d-none") + $("#colorpicker").addClass("d-none") + this.mode === ConceptMap.addEdge && $("#delete").addClass("d-none") + this.mode === ConceptMap.editEdge && $("#delete").removeClass("d-none") + $("#x").attr("value", canvasX) + $("#y").attr("value", canvasY) + this.focus("#entry_link") + } + + showForm = (canvasX, canvasY) => { + // attach close handler for clicks elsewhere + this.network.once("click", (params) => { + if (!params.nodes.length && !params.edges.length) { + this.hideForm() + } + }) + + $("#edit-dialog") + .removeClass("d-none") + .attr("style", "z-index: 1; position:absolute;left:" + ($("#map-canvas").offset().left + this.network.canvasToDOM({ x: canvasX, y: canvasY }).x - $("#form").width() / 2) + "px;top:" + ($("#map-canvas").offset().top + this.network.canvasToDOM({ x: canvasX, y: canvasY }).y - $("#form").height() / 2) + "px;") + switch (this.mode) { + case ConceptMap.addNode: + $('#context-help-text').html($('#ch_new').html()) + $('#action').html(this.dialogTexts.addNode) + $("#entry_concept").val("") + this.initNodeInputs(canvasX, canvasY) + break + case ConceptMap.editNode: + $('#context-help-text').html($('#ch_edit').html()) + $('#action').html(this.dialogTexts.editNode) + $("#entry_concept").val(this.nodes.get(this.id).label) + // DH: ADD the ID, needed to compare and reset the link_id + $("#concept_id").val(this.nodes.get(this.id).id) + // DH: Set the lock still on true + $("#lock").val("true") + $("#link_id").val("") + this.initNodeInputs(canvasX, canvasY) + this.selectColor(this.nodes.get(this.id).color.background) + break + case ConceptMap.editEdge: + $('#context-help-text').html($('#ch_edit').html()) + $('#action').html(this.dialogTexts.editEdge) + $("#entry_link").val(this.edges.get(this.id).label) + // DH: ADD the ID, needed to compare and reset the concept_id + $("#link_id").val(this.edges.get(this.id).id) + $("#link_lock").val("true") + $("#concept_id").val("") + this.initEdgeInputs(canvasX, canvasY) + break + case ConceptMap.addEdge: + $('#context-help-text').html($('#ch_new').html()) + $('#action').html(this.dialogTexts.addEdge) + $("#entry_link").val("") + this.initEdgeInputs(canvasX, canvasY) + } + } + + //Edit/Create Aktion beenden + Lock aufheben + hideForm = async () => { + + if(this.mode == ConceptMap.editNode ) { + //DH: Node Lock aufheben + const res = await fetch(this.conceptsPath + "/" + this.id, { + "method": "put", + "mode": "same-origin", + "headers": { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRF-Token': $('meta[name="csrf-token"]').attr('content') + }, + "body": JSON.stringify({ "concept": { 'label': this.nodes.get(this.id).label, 'lock': 'false', 'x': this.nodes.get(this.id).x, 'y': this.nodes.get(this.id).y } }) + }) + const body = await res.json() + this.nodes.update(body.node) + + } else if(this.mode == ConceptMap.editEdge) { + // DH: Edge Lock aufheben + const res = await fetch(this.linksPath + "/" + this.id, { + "method": "put", + "mode": "same-origin", + "headers": { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRF-Token': $('meta[name="csrf-token"]').attr('content') + }, + "body": JSON.stringify({ "link": { 'label': this.edges.get(this.id).label, 'lock': 'false', "start_id": this.edges.get(this.id).from, "end_id": this.edges.get(this.id).to } }) + }) + const body = await res.json() + this.edges.update(body.edge) + } + + + $("#edit-dialog").addClass("d-none") + $("#edit-dialog").focusout() + this.network.unselectAll() + $('#context-help-text').html($('#ch_normal').html()) + //Zoom-Out by Mobilgeräten veranlassen + const viewport = document.querySelector('meta[name="viewport"]') + if (viewport) { + viewport.content = 'initial-scale=1' + viewport.content = 'width=device-width' + viewport.content = 'maximum-scale=1' + } + + this.mode = ConceptMap.none + } + sendMail = () => { + $('#emailgroup').removeClass('has-error') + $('#emailgroup').removeClass('has-success') + $('#submit').removeClass('btn-danger') + $('#submit').removeClass('btn-success') + if ($('#email').is(':valid') && $('#email').val() != '') { + $.ajax({ url: this.conceptMapsPath + '?email=' + $('#email').val() }) + $('#submit').addClass('btn-success') + $('#emailgroup').addClass('has-success') + } + else { + $('#submit').addClass('btn-danger') + $('#emailgroup').addClass('has-error') + } + } + + searchConcept = (searchTerm) => { + if (searchTerm === "") { + $('#searchGroup').removeClass('has-error') + $('#searchGroup').removeClass('has-success') + return + } + const node = this.nodes.get({ + filter: (node) => { + return (node.label.slice(0, searchTerm.length).toLocaleLowerCase() === searchTerm.toLocaleLowerCase()) + } + }) + if (node && node.length > 0) { + this.network.focus(node[0].id) + $('#searchGroup').removeClass('has-error') + $('#searchGroup').addClass('has-success') + } + else { + $('#searchGroup').removeClass('has-success') + $('#searchGroup').addClass('has-error') + } + } + + /********************************* + * // DH: external setter for node/edge data and mode + ********************************/ + setNodeData = (nodeData) => { + this.nodes.update(nodeData) + } + + setEdgeData = (edgeData) => { + this.edges.update(edgeData) + } + + // DH Make the mode available for the channel + setMode = (mode) => { + this.mode = mode + } + +} + + +export default ConceptMap diff --git a/app/javascript/add_jquery.js b/app/javascript/add_jquery.js new file mode 100644 index 00000000..f1a04b29 --- /dev/null +++ b/app/javascript/add_jquery.js @@ -0,0 +1,3 @@ +import jquery from 'jquery' +window.jQuery = jquery +window.$ = jquery \ No newline at end of file diff --git a/vendor/assets/javascripts/.keep b/app/javascript/application.js similarity index 100% rename from vendor/assets/javascripts/.keep rename to app/javascript/application.js diff --git a/app/models/concept_map.rb b/app/models/concept_map.rb index 1a3ed420..735837a5 100644 --- a/app/models/concept_map.rb +++ b/app/models/concept_map.rb @@ -1,5 +1,4 @@ class ConceptMap < ApplicationRecord - after_create :after_create belongs_to :survey @@ -7,6 +6,9 @@ class ConceptMap < ApplicationRecord has_many :links, dependent: :destroy has_many :versions, dependent: :destroy + has_many :students, dependent: :destroy + accepts_nested_attributes_for :concepts, :links # enable update of multiple concepts and links at once + #Use slug instead of id for routing #Return: Code of concept map def to_param @@ -32,21 +34,27 @@ def first? def after_create self.accesses ||= 0 unless survey.concept_labels.blank? - labels = survey.concept_labels.split(',').map{|s| s.strip}.uniq - step = 2*Math::PI/labels.length + labels = survey.concept_labels.split(',').map { |s| s.strip }.uniq + step = 2 * Math::PI / labels.length count = 0 labels.each do |c| - concepts.build(label: c, x: (labels.length/5.0)*100*(Math.sin(count*step) + 1), y: (labels.length/5.0)*100*(Math.cos(count*step) + 1)).save + concepts.build( + label: c, + x: (labels.length / 5.0) * 100 * (Math.sin(count * step) + 1), + y: (labels.length / 5.0) * 100 * (Math.cos(count * step) + 1) + ).save count = count + 1 end save else - unless survey.initial_map.blank? - from_tgf(survey.initial_map) - end + from_tgf(survey.initial_map) unless survey.initial_map.blank? end - while self.code.nil? || self.code.blank? || Survey.where(code: self.code).exists? || (ConceptMap.where(code: self.code).exists? && ConceptMap.find_by_code(self.code) != self) + while self.code.nil? || self.code.blank? || Survey.where(code: self.code).exists? || + ( + ConceptMap.where(code: self.code).exists? && + ConceptMap.find_by_code(self.code) != self + ) self.code = ConceptMap.generate_slug end save @@ -54,15 +62,21 @@ def after_create versionize(DateTime.now) end + # returns true if the map has no concepts + def has_concepts + return Concept.where(concept_map_id: self.id).length > 0 + end + #Retrieve a map by code or find an available survey and create a new map #Params: # code: Either a concept map slug or a survey code #Effect: If no map but a suitable survey is found, a new map will be created for this survey #Returns: The identified map or a newly created map or nil if neither map nor survey is found. def self.prepare_map(code) - map = ConceptMap.find_by_code(code) #Check if a map with the given code already exists - survey = Survey.find_by_code(code) #Check if a survey with the given code exists - if map.nil? && !survey.nil? && survey.available #No map, but availabe survey => create a new map + map = ConceptMap.find_by_code(code) #Check if a map with the given code already exists + survey = Survey.find_by_code(code) #Check if a survey with the given code exists + if map.nil? && !survey.nil? && survey.available + #No map, but availabe survey => create a new map map = survey.concept_maps.build map.save end @@ -81,7 +95,7 @@ def versionize(date) save end - #Import data from aeither a ZIP file in the same format that to_zip creates, or a JSON file + #Import data from either a ZIP file in the same format that to_zip creates, or a JSON file #Parameter: # file: Path to a file # code: Will be used as an initial code for the map. May be overwritten if importing from JSON. @@ -92,9 +106,9 @@ def import_file(file, code) save temp = file.path.split('.') type = temp[-1].downcase - return from_json(File.read(file), 'I_') if type == "json" - return from_tgf(File.read(file)) if type == "tgf" - return import_zip(file, '') if type == "zip" + return from_json(File.read(file), 'I_') if type == 'json' + return from_tgf(File.read(file)) if type == 'tgf' + return import_zip(file, '') if type == 'zip' end #Imports concepts and associations based on a JSON representation of a concept map object @@ -106,17 +120,21 @@ def import_file(file, code) def from_json(data, code_prefix) vals = ActiveSupport::JSON.decode(data) dict = Hash.new - self.code = code_prefix + (vals["code"] || '') + self.code = code_prefix + (vals['code'] || '') save - vals["concepts"].each do |c| - t = self.concepts.build(label: c["label"], x: c["x"], y: c["y"]) - t.save - t.reload - dict[c["id"]] = t + unless vals['concepts'].nil? + vals['concepts'].each do |c| + t = self.concepts.build(label: c['label'], x: c['x'], y: c['y']) + t.save + t.reload + dict[c['id']] = t + end end - vals["links"].each do |l| - t = self.links.build(label: l["label"], start: dict[l["start_id"]], end: dict[l["end_id"]]) - t.save + unless vals['links'].nil? + vals['links'].each do |l| + t = self.links.build(label: l['label'], start: dict[l['start_id']], end: dict[l['end_id']]) + t.save + end end return save end @@ -137,12 +155,17 @@ def from_tgf(data) end dict = Hash.new unless node_defs.nil? - step = 2*Math::PI/node_defs.lines.count + step = 2 * Math::PI / node_defs.lines.count count = 0 node_defs.each_line do |line| l = line.split(' ', 2) unless (l[0].nil? || l[1].nil? || l[0].blank? || l[1].blank?) - c = concepts.build(label: l[1].strip, x: (node_defs.lines.count/5.0)*100*(Math.sin(count*step) + 1), y: (node_defs.lines.count/5.0)*100*(Math.cos(count*step) + 1)) + c = + concepts.build( + label: l[1].strip, + x: (node_defs.lines.count / 5.0) * 100 * (Math.sin(count * step) + 1), + y: (node_defs.lines.count / 5.0) * 100 * (Math.cos(count * step) + 1) + ) c.save dict[l[0]] = c count = count + 1 @@ -152,7 +175,10 @@ def from_tgf(data) unless edge_defs.nil? edge_defs.each_line do |line| l = line.split(' ', 3) - unless (l[0].nil? || l[1].nil? || dict[l[0]].nil? || dict[l[1]].nil? || l[2].nil? || l[2].blank?) + unless ( + l[0].nil? || l[1].nil? || dict[l[0]].nil? || dict[l[1]].nil? || l[2].nil? || + l[2].blank? + ) links.build(start: dict[l[0]], end: dict[l[1]], label: l[2].strip).save end end @@ -177,20 +203,16 @@ def import_zip(file, prefix) name = c.name.split('/')[-1] name ||= c.name type = name.split('.')[-1] - if type == "json" - res = res && from_json(c.get_input_stream.read, 'I_') - end - if type == "tgf" - res = res && from_tgf(c.get_input_stream.read) - end + res = res && from_json(c.get_input_stream.read, 'I_') if type == 'json' + res = res && from_tgf(c.get_input_stream.read) if type == 'tgf' versionize(DateTime.parse(name.split('.')[0..-2].join(':'))) - if pos < toDo.size-1 + if pos < toDo.size - 1 concepts.clear concepts.reload links.clear links.reload end - pos = pos+1 + pos = pos + 1 end return res end @@ -200,7 +222,17 @@ def import_zip(file, prefix) #Effect: - #Returns: JSON data of the concept map def to_json - as_json(include: {concepts: {only: [:id, :label, :x, :y]}, links: {only: [:id, :label, :start_id, :end_id]}}, only: [:id, :code]).to_json + as_json( + include: { + concepts: { + only: %i[id label x y color shape] + }, + links: { + only: %i[id label start_id end_id] + } + }, + only: %i[id code] + ).to_json end #Creates a TGF representation of the map @@ -208,14 +240,12 @@ def to_json #Effect: - #Returns: TGF data of the concept map def to_tgf - reload(:include => [:concepts, :links]) - res = "" - self.concepts.each do |concept| - res = res + concept.id.to_s + " " + concept.label + "\n" - end + reload(include: %i[concepts links]) + res = '' + self.concepts.each { |concept| res = res + concept.id.to_s + ' ' + concept.label + "\n" } res = res + "#\n" self.links.each do |edge| - res = res + edge.start_id.to_s + " " + edge.end_id.to_s + " " + edge.label + "\n" + res = res + edge.start_id.to_s + ' ' + edge.end_id.to_s + ' ' + edge.label + "\n" end return res end @@ -226,10 +256,8 @@ def to_tgf #Effect: - #Returns: Path to a temporary Zip file def to_zip(tgf) - temp = Tempfile.new("CoMapEd") - Zip::OutputStream.open(temp.path) do |zip| - write_stream('', zip, tgf) - end + temp = Tempfile.new('CoMapEd') + Zip::OutputStream.open(temp.path) { |zip| write_stream('', zip, tgf) } temp.close return temp.path end @@ -244,13 +272,24 @@ def to_zip(tgf) def write_stream(prefix, zip, tgf) self.versions.each do |v| if tgf - zip.put_next_entry((prefix + v.created_at.strftime("%Y-%m-%d %H.%M.%S") + ".tgf").encode!('CP437', :undefined => :replace, :replace => '_')) + zip.put_next_entry( + (prefix + v.created_at.strftime('%Y-%m-%d %H.%M.%S') + '.tgf').encode!( + 'CP437', + undefined: :replace, + replace: '_' + ) + ) zip.print v.to_tgf else - zip.put_next_entry((prefix + v.created_at.strftime("%Y-%m-%d %H.%M.%S") + ".json").encode!('CP437', :undefined => :replace, :replace => '_')) - zip.print v.data + zip.put_next_entry( + (prefix + v.created_at.strftime('%Y-%m-%d %H.%M.%S') + '.json').encode!( + 'CP437', + undefined: :replace, + replace: '_' + ) + ) + zip.print v.to_json end end end - end diff --git a/app/models/link.rb b/app/models/link.rb index dc5ebecd..8055c253 100644 --- a/app/models/link.rb +++ b/app/models/link.rb @@ -2,6 +2,6 @@ class Link < ApplicationRecord validates :label, presence: true belongs_to :concept_map - belongs_to :start, class_name: Concept - belongs_to :end, class_name: Concept + belongs_to :start, class_name: Concept.to_s + belongs_to :end, class_name: Concept.to_s end diff --git a/app/models/project.rb b/app/models/project.rb index cc4da8ab..58518e65 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -45,7 +45,7 @@ def import_file(file) #Returns: true if the update succeeded, false if an error occurred def from_json(data, name_prefix) vals = ActiveSupport::JSON.decode(data) - update_attributes(vals.slice("name", "description")) + update(vals.slice("name", "description")) self.name = name_prefix + self.name return save end diff --git a/app/models/student.rb b/app/models/student.rb new file mode 100644 index 00000000..bb817ec0 --- /dev/null +++ b/app/models/student.rb @@ -0,0 +1,36 @@ +class Student < ApplicationRecord + # DH: a student belongs to one map + belongs_to :concept_map + + # DH: Make sure the student name is unqiue for a concept map + validates :name, presence: true, uniqueness: { scope: :concept_map_id } + validates :color, presence: true, uniqueness: { scope: :concept_map_id } + + def self.generate(c_id) + # generate a random name for a student + adjectives = ["Crazy", "Happy", "Creative", "Dangerous", "Effective", "Flying"] + nouns = ["Cat", "Tiger", "Dog", "Shark", "Lion", "Bird", "Rabbit", "Dolphin", "Bear", "Elephant", "Butterfly", "Snake", "Duck", "Chicken"] + + # each student gets a unique color as well: 1 out of 20 (more can be added) + colors = ['#e6194B', '#3cb44b', '#ffe119', '#4363d8', '#f58231', '#911eb4', '#42d4f4', '#f032e6', '#bfef45', + '#fabed4', '#469990', '#dcbeff', '#9A6324', '#fffac8', '#800000', '#aaffc3', '#808000', '#ffd8b1', '#000075', + '#000000'] + + random_number = rand.to_s[2..4] + # create the random name and color + name = "#{adjectives.sample}-#{nouns.sample}" + color = "#{colors.sample}" + + # create the student + create(name: name, concept_map_id: c_id, color: color) + end + + def self.delete_inactive + # Delete all students, who have not been active for half an hour + Student.where('updated_at < ?', DateTime.now - (0.5/24.0)).delete_all + + #for s in deleted + #ActionCable.server.broadcast("test_channel", {action: "user_left", user_id: s.id, map_id: s.concept_map_id}) + #end + end +end diff --git a/app/models/survey.rb b/app/models/survey.rb index 34fa2c19..1a70abd4 100644 --- a/app/models/survey.rb +++ b/app/models/survey.rb @@ -2,7 +2,7 @@ class Survey < ApplicationRecord validates :name, presence: true - validates :code, uniqueness: true, if: 'code.present?' + validates :code, uniqueness: true, if: -> {code.present?} belongs_to :project has_many :concept_maps, dependent: :destroy @@ -35,7 +35,7 @@ def import_file(file) #Returns: true if the update succeeded, false if an error occurred def from_json(data, name_prefix) vals = ActiveSupport::JSON.decode(data) - update_attributes(vals.slice("name", "description", "introduction", "association_labels", "concept_labels", "initial_map")) + update(vals.slice("name", "description", "introduction", "association_labels", "concept_labels", "initial_map")) self.name = name_prefix + self.name return save end @@ -125,7 +125,7 @@ def write_stream(prefix, zip, tgf, versions) zip.print map.to_tgf else zip.put_next_entry((prefix + map.code + ".json").encode!('CP437', :undefined => :replace, :replace => '_')) - zip.print map.to_tgf + zip.print map.to_json end end end diff --git a/app/models/user.rb b/app/models/user.rb index df5afacc..072b50a3 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,5 +1,8 @@ class User < ApplicationRecord - validates :email, presence: true, uniqueness: true, format: /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/ + validates :email, + presence: true, + uniqueness: true, + format: /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/ has_secure_password has_many :projects, dependent: :destroy @@ -9,7 +12,7 @@ class User < ApplicationRecord #Effect: - #Returns: true if the use has admin capabilities, false otherwise def admin? - self.capabilities == "admin" + self.capabilities == 'admin' end #Count all concept maps of this user @@ -17,8 +20,8 @@ def admin? #Effect: - #Returns: Number of all concept maps that belong to this user def concept_map_count - surveys = Survey.where(:project_id => projects) - ConceptMap.where(:survey_id => surveys).count + surveys = Survey.where(project_id: projects) + ConceptMap.where(survey_id: surveys).count end #Search for a user with the given credentials and return it diff --git a/app/views/application/_imprint.de.html.erb b/app/views/application/_imprint.de.html.erb index 89760380..29790dbe 100644 --- a/app/views/application/_imprint.de.html.erb +++ b/app/views/application/_imprint.de.html.erb @@ -1,33 +1,36 @@ -

    Anschrift und Kontakt

    +
    +

    Anschrift und Kontakt

    -
    - Christian-Albrechts-Universität zu Kiel - Christian-Albrechts-Platz 4
    - 24118 Kiel, Germany
    -
    +
    + Christian-Albrechts-Universität zu Kiel + Christian-Albrechts-Platz 4
    + 24118 Kiel, Germany
    +
    -

    - Andreas Mühling (andreas.muehling@informatik.uni-kiel.de) -

    +

    + Andreas Mühling (andreas.muehling@informatik.uni-kiel.de) + +49 431 880-6839 +

    -
    -

    Haftungsausschluss

    +
    +

    Haftungsausschluss

    -
    -

    Haftung für Inhalte

    +
    +

    Haftung für Inhalte

    -

    - Als Diensteanbieter sind wir gemäß § 7 Abs.1 TMG für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 TMG sind wir als Diensteanbieter jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen. Verpflichtungen zur Entfernung oder Sperrung der Nutzung von Informationen nach den allgemeinen Gesetzen bleiben hiervon unberührt. Eine diesbezügliche Haftung ist jedoch erst ab dem Zeitpunkt der Kenntnis einer konkreten Rechtsverletzung möglich. Bei Bekanntwerden von entsprechenden Rechtsverletzungen werden wir diese Inhalte umgehend entfernen. -

    +

    + Als Diensteanbieter sind wir gemäß § 7 Abs.1 TMG für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 TMG sind wir als Diensteanbieter jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen. Verpflichtungen zur Entfernung oder Sperrung der Nutzung von Informationen nach den allgemeinen Gesetzen bleiben hiervon unberührt. Eine diesbezügliche Haftung ist jedoch erst ab dem Zeitpunkt der Kenntnis einer konkreten Rechtsverletzung möglich. Bei Bekanntwerden von entsprechenden Rechtsverletzungen werden wir diese Inhalte umgehend entfernen. +

    -
    -

    Haftung für Links

    -

    - Unser Angebot enthält Links zu externen Webseiten Dritter, auf deren Inhalte wir keinen Einfluss haben. Deshalb können wir für diese fremden Inhalte auch keine Gewähr übernehmen. Für die Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber der Seiten verantwortlich. Die verlinkten Seiten wurden zum Zeitpunkt der Verlinkung auf mögliche Rechtsverstöße überprüft. Rechtswidrige Inhalte waren zum Zeitpunkt der Verlinkung nicht erkennbar. Eine permanente inhaltliche Kontrolle der verlinkten Seiten ist jedoch ohne konkrete Anhaltspunkte einer Rechtsverletzung nicht zumutbar. Bei Bekanntwerden von Rechtsverletzungen werden wir derartige Links umgehend entfernen. -

    +
    +

    Haftung für Links

    +

    + Unser Angebot enthält Links zu externen Webseiten Dritter, auf deren Inhalte wir keinen Einfluss haben. Deshalb können wir für diese fremden Inhalte auch keine Gewähr übernehmen. Für die Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber der Seiten verantwortlich. Die verlinkten Seiten wurden zum Zeitpunkt der Verlinkung auf mögliche Rechtsverstöße überprüft. Rechtswidrige Inhalte waren zum Zeitpunkt der Verlinkung nicht erkennbar. Eine permanente inhaltliche Kontrolle der verlinkten Seiten ist jedoch ohne konkrete Anhaltspunkte einer Rechtsverletzung nicht zumutbar. Bei Bekanntwerden von Rechtsverletzungen werden wir derartige Links umgehend entfernen. +

    -
    -

    Urheberrecht

    -

    - Die durch die Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem deutschen Urheberrecht. Die Vervielfältigung, Bearbeitung, Verbreitung und jede Art der Verwertung außerhalb der Grenzen des Urheberrechtes bedürfen der schriftlichen Zustimmung des jeweiligen Autors bzw. Erstellers. Downloads und Kopien dieser Seite sind nur für den privaten, nicht kommerziellen Gebrauch gestattet. Soweit die Inhalte auf dieser Seite nicht vom Betreiber erstellt wurden, werden die Urheberrechte Dritter beachtet. Insbesondere werden Inhalte Dritter als solche gekennzeichnet. Sollten Sie trotzdem auf eine Urheberrechtsverletzung aufmerksam werden, bitten wir um einen entsprechenden Hinweis. Bei Bekanntwerden von Rechtsverletzungen werden wir derartige Inhalte umgehend entfernen. -

    +
    +

    Urheberrecht

    +

    + Die durch die Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem deutschen Urheberrecht. Die Vervielfältigung, Bearbeitung, Verbreitung und jede Art der Verwertung außerhalb der Grenzen des Urheberrechtes bedürfen der schriftlichen Zustimmung des jeweiligen Autors bzw. Erstellers. Downloads und Kopien dieser Seite sind nur für den privaten, nicht kommerziellen Gebrauch gestattet. Soweit die Inhalte auf dieser Seite nicht vom Betreiber erstellt wurden, werden die Urheberrechte Dritter beachtet. Insbesondere werden Inhalte Dritter als solche gekennzeichnet. Sollten Sie trotzdem auf eine Urheberrechtsverletzung aufmerksam werden, bitten wir um einen entsprechenden Hinweis. Bei Bekanntwerden von Rechtsverletzungen werden wir derartige Inhalte umgehend entfernen. +

    +
    \ No newline at end of file diff --git a/app/views/application/_imprint.en.html.erb b/app/views/application/_imprint.en.html.erb index 5e6f717d..0d957d6c 100644 --- a/app/views/application/_imprint.en.html.erb +++ b/app/views/application/_imprint.en.html.erb @@ -1,39 +1,42 @@ -

    Contact

    - -
    - Christian-Albrechts-Universität zu Kiel - Christian-Albrechts-Platz 4
    - 24118 Kiel, Germany
    -
    - -

    - Andreas Mühling (andreas.muehling@informatik.uni-kiel.de) -

    - -
    - -

    Limitation of liability for internal content

    - -

    - The content of our website has been compiled with meticulous care and to the best of our knowledge. However, we cannot assume any liability for the up-to-dateness, completeness or accuracy of any of the pages. - Pursuant to section 7, para. 1 of the TMG (Telemediengesetz – Tele Media Act by German law), we as service providers are liable for our own content on these pages in accordance with general laws. - However, pursuant to sections 8 to 10 of the TMG, we as service providers are not under obligation to monitor external information provided or stored on our website. - Once we have become aware of a specific infringement of the law, we will immediately remove the content in question. - Any liability concerning this matter can only be assumed from the point in time at which the infringement becomes known to us. -

    - -

    Limitation of liability for external links

    - -

    - Our website contains links to the websites of third parties („external links“). - As the content of these websites is not under our control, we cannot assume any liability for such external content. - In all cases, the provider of information of the linked websites is liable for the content and accuracy of the information provided. - At the point in time when the links were placed, no infringements of the law were recognisable to us. As soon as an infringement of the law becomes known to us, we will immediately remove the link in question. -

    - -

    Copyright

    - -

    - The content and works published on this website are governed by the copyright laws of Germany. - Any duplication, processing, distribution or any form of utilisation beyond the scope of copyright law shall require the prior written consent of the author or authors in question. -

    \ No newline at end of file +
    +

    Contact

    + +
    + Christian-Albrechts-Universität zu Kiel + Christian-Albrechts-Platz 4
    + 24118 Kiel, Germany
    +
    + +

    + Andreas Mühling (andreas.muehling@informatik.uni-kiel.de) + +49 431 880-6839 +

    + +
    + +

    Limitation of liability for internal content

    + +

    + The content of our website has been compiled with meticulous care and to the best of our knowledge. However, we cannot assume any liability for the up-to-dateness, completeness or accuracy of any of the pages. + Pursuant to section 7, para. 1 of the TMG (Telemediengesetz – Tele Media Act by German law), we as service providers are liable for our own content on these pages in accordance with general laws. + However, pursuant to sections 8 to 10 of the TMG, we as service providers are not under obligation to monitor external information provided or stored on our website. + Once we have become aware of a specific infringement of the law, we will immediately remove the content in question. + Any liability concerning this matter can only be assumed from the point in time at which the infringement becomes known to us. +

    + +

    Limitation of liability for external links

    + +

    + Our website contains links to the websites of third parties („external links“). + As the content of these websites is not under our control, we cannot assume any liability for such external content. + In all cases, the provider of information of the linked websites is liable for the content and accuracy of the information provided. + At the point in time when the links were placed, no infringements of the law were recognisable to us. As soon as an infringement of the law becomes known to us, we will immediately remove the link in question. +

    + +

    Copyright

    + +

    + The content and works published on this website are governed by the copyright laws of Germany. + Any duplication, processing, distribution or any form of utilisation beyond the scope of copyright law shall require the prior written consent of the author or authors in question. +

    +
    diff --git a/app/views/application/_legal.html.erb b/app/views/application/_legal.html.erb index 2aa43382..8a651555 100644 --- a/app/views/application/_legal.html.erb +++ b/app/views/application/_legal.html.erb @@ -2,35 +2,35 @@