diff --git a/plugins/additionals/.rubocop.yml b/plugins/additionals/.rubocop.yml index 7fcb391..51b9c7e 100755 --- a/plugins/additionals/.rubocop.yml +++ b/plugins/additionals/.rubocop.yml @@ -1,23 +1,28 @@ +require: + - rubocop-performance + - rubocop-rails + Rails: Enabled: true AllCops: - TargetRubyVersion: 2.2 + TargetRubyVersion: 2.4 TargetRailsVersion: 5.2 + NewCops: enable Metrics/AbcSize: - Max: 65 + Enabled: false Metrics/BlockLength: - Max: 60 + Enabled: false Metrics/ClassLength: Enabled: false Metrics/CyclomaticComplexity: - Max: 20 + Max: 25 -Metrics/LineLength: +Layout/LineLength: Max: 140 Metrics/MethodLength: @@ -29,21 +34,39 @@ Metrics/ModuleLength: Metrics/PerceivedComplexity: Max: 25 -Rails/SkipsModelValidations: +Rails/ApplicationJob: + Enabled: false + +Rails/ApplicationRecord: Enabled: false Rails/CreateTableWithTimestamps: Enabled: false -# app/model/application_record.rb is missing in redmine, we can't use ApplicationRecord -Rails/ApplicationRecord: +Rails/HelperInstanceVariable: Enabled: false +Rails/SkipsModelValidations: + Enabled: false + +Performance/ChainArrayAllocation: + Enabled: true + Style/AutoResourceCleanup: Enabled: true +Style/FrozenStringLiteralComment: + Enabled: false + Style/Documentation: Enabled: false +# required for travis/ci (symbolic links problem) Style/ExpandPathArguments: Enabled: false + +Style/HashTransformKeys: + Enabled: false + +Style/HashTransformValues: + Enabled: false diff --git a/plugins/additionals/.slim-lint.yml b/plugins/additionals/.slim-lint.yml index aea15c3..54689e6 100755 --- a/plugins/additionals/.slim-lint.yml +++ b/plugins/additionals/.slim-lint.yml @@ -3,16 +3,16 @@ linters: max: 140 RuboCop: ignored_cops: - - Layout/AlignArray - - Layout/AlignHash - - Layout/AlignParameters + - Layout/ArgumentAlignment + - Layout/ArrayAlignment - Layout/BlockEndNewline - Layout/EmptyLineAfterGuardClause - - Layout/FirstParameterIndentation - - Layout/IndentArray + - Layout/HashAlignment - Layout/IndentationConsistency - Layout/IndentationWidth - - Layout/IndentHash + - Layout/IndentFirstArgument + - Layout/IndentFirstArrayElement + - Layout/IndentFirstHashElement - Layout/MultilineArrayBraceLayout - Layout/MultilineAssignmentLayout - Layout/MultilineBlockLayout @@ -22,6 +22,7 @@ linters: - Layout/MultilineMethodDefinitionBraceLayout - Layout/MultilineOperationIndentation - Layout/TrailingBlankLines + - Layout/TrailingEmptyLines - Layout/TrailingWhitespace - Lint/BlockAlignment - Lint/EndAlignment diff --git a/plugins/additionals/.travis.yml b/plugins/additionals/.travis.yml index 7c7aa27..f826712 100755 --- a/plugins/additionals/.travis.yml +++ b/plugins/additionals/.travis.yml @@ -1,26 +1,21 @@ language: ruby +os: linux +dist: xenial rvm: - - 2.5.3 - - 2.4.5 - - 2.3.8 + - 2.6.6 + - 2.5.8 + - 2.4.10 + +services: + - mysql + - postgresql env: - - REDMINE_VER=4.0-stable DB=postgresql - - REDMINE_VER=3.4-stable DB=postgresql - - REDMINE_VER=4.0-stable DB=mysql - - REDMINE_VER=3.4-stable DB=mysql - -sudo: true - -addons: - postgresql: "9.6" - apt: - sources: - - mysql-5.7-trusty - packages: - - mysql-server - - mysql-client + - REDMINE_VER=4.1-stable DB=postgresql + - REDMINE_VER=master DB=postgresql + - REDMINE_VER=4.1-stable DB=mysql + - REDMINE_VER=master DB=mysql before_install: - export PLUGIN_NAME=additionals @@ -31,19 +26,18 @@ before_install: - git clone $REDMINE_GIT_REPO $REDMINE_PATH - cd $REDMINE_PATH - if [[ "$REDMINE_VER" != "master" ]]; then git checkout -b $REDMINE_VER origin/$REDMINE_VER; fi + - sed -i '/rubocop/d' $REDMINE_PATH/Gemfile + - rm -f $REDMINE_PATH/.rubocop* + - cp $TRAVIS_BUILD_DIR/test/support/Gemfile.local $REDMINE_PATH - ln -s $TRAVIS_BUILD_DIR $REDMINE_PATH/plugins/$PLUGIN_NAME - cp $TRAVIS_BUILD_DIR/test/support/additional_environment.rb $REDMINE_PATH/config/ - cp $TRAVIS_BUILD_DIR/test/support/database-$DB-travis.yml $REDMINE_PATH/config/database.yml before_script: - - if [[ "$DB" == "mysql" ]]; then mysql -e "use mysql; update user set authentication_string=PASSWORD('travis_ci_test') where User='root'; update user set plugin='mysql_native_password';FLUSH PRIVILEGES;"; fi - - if [[ "$DB" == "mysql" ]]; then mysql_upgrade -ptravis_ci_test; fi - - if [[ "$DB" == "mysql" ]]; then service mysql restart; fi - bundle exec rake db:create db:migrate redmine:plugins:migrate script: - - export SKIP_COVERAGE=1 - - if [[ "$REDMINE_VER" == "master" ]]; then bundle exec rake redmine:plugins:test:units NAME=$PLUGIN_NAME; fi - - if [[ "$REDMINE_VER" == "master" ]]; then bundle exec rake redmine:plugins:test:functionals NAME=$PLUGIN_NAME; fi - - if [[ "$REDMINE_VER" == "master" ]]; then bundle exec rake redmine:plugins:test:integration NAME=$PLUGIN_NAME; fi - - if [[ "$REDMINE_VER" != "master" ]]; then bundle exec rake redmine:plugins:test NAME=$PLUGIN_NAME RUBYOPT="-W0"; fi + - if [[ "$REDMINE_VER" != "master" ]] && [[ "$DB" == "postgresql" ]]; then brakeman plugins/$PLUGIN_NAME; fi + - if [[ "$REDMINE_VER" != "master" ]] && [[ "$DB" == "postgresql" ]]; then rubocop plugins/$PLUGIN_NAME; fi + - bundle exec rake redmine:plugins:test NAME=$PLUGIN_NAME RUBYOPT="-W0" + - bundle exec rake redmine:plugins:migrate NAME=$PLUGIN_NAME VERSION=0 diff --git a/plugins/additionals/CHANGELOG.rst b/plugins/additionals/CHANGELOG.rst index d1fe28c..7e6afa6 100755 --- a/plugins/additionals/CHANGELOG.rst +++ b/plugins/additionals/CHANGELOG.rst @@ -1,6 +1,74 @@ Changelog ========= +3.0.0 ++++++ + +- Introduce dashboards +- Redmine 4.1 or newer is required +- FontAwesome 5.14.0 support +- D3 6.1.1 support +- Mermaid 8.8.0 support +- d3plus to v2.0.0-alpha.29 support +- drop wiki header and footer settings + +2.0.24 +++++++ + +- FontAwesome 5.13.0 support +- Mermaid 8.4.8 support +- clipboard.js updated to v2.0.6 +- fix for spam protection with invisible_captcha +- D3 5.16.0 support +- Ruby 2.4 is required + +2.0.23 +++++++ + +- members macro now supports with_sum option +- FontAwesome 5.12 support +- FontAwesome ajax search has been added +- Mermaid 8.4.6 support +- D3 5.15.0 support +- Drop nvd3 library +- Drop Chartjs stacked100 library +- Drop d3plus-hierarchy library +- Drop calendar macro +- Support private comments with issue macro +- Google Docs macro has been added +- Fix bug with Rack 2.0.8 or newer +- Drop Redmine 3.4 support +- Add Redmine 4.1 support +- Use view_layouts_base_body_top hook, which is available since Redmine 3.4 +- Refactoring new hooks (without template) +- asciinema.org macro has been added - thanks to @kotashiratsuka +- Select2 4.0.13 support + +2.0.22 +++++++ + +- FontAwesome 5.11.2 support +- Mermaid 8.4.2 support +- Select2 4.0.12 support +- Chart.js 2.9.3 support +- Chart.js Plugin datalabels 0.7.0 support +- d3plus to v2.0.0-alpha.25 +- Fix user visibility for members macro +- Fix user visibility for issue reports +- Drop ZeroClipboard library + +2.0.21 +++++++ + +- fix mail notification if issue author changed +- fix permission bug for closed issues with freezed mode +- Ruby 2.2.x support has been dropped. Use 2.3.x or newer ruby verion +- FontAwesome 5.9.0 support +- remove issue_close_with_open_children functionality, because this is included in Redmine 3.4.x #47 (thanks to @pva) +- add hierarchy support for projects macro #45 +- select2 support +- bootstrap-datepicker 1.9.0 support + 2.0.20 ++++++ diff --git a/plugins/additionals/Gemfile b/plugins/additionals/Gemfile index a1f9a7f..4288b35 100755 --- a/plugins/additionals/Gemfile +++ b/plugins/additionals/Gemfile @@ -1,16 +1,13 @@ -gem 'deface', '>= 1.1.0' +source 'https://rubygems.org' + +gem 'deface', '1.5.3' gem 'gemoji', '~> 3.0.0' gem 'invisible_captcha' +gem 'render_async' +gem 'rss' gem 'slim-rails' -group :test do +group :development, :test do gem 'brakeman', require: false - gem 'rubocop', require: false gem 'slim_lint', require: false end - -#group :development do -# gem 'awesome_print', require: 'ap' # https://github.com/awesome-print/awesome_print -# gem 'better_errors' # https://github.com/BetterErrors/better_errors -# gem 'binding_of_caller' # better output of with variables for better_errors -#end diff --git a/plugins/additionals/README.rst b/plugins/additionals/README.rst new file mode 100644 index 0000000..d46ade8 --- /dev/null +++ b/plugins/additionals/README.rst @@ -0,0 +1 @@ +docs/index.rst \ No newline at end of file diff --git a/plugins/additionals/app/controllers/additionals_assign_to_me_controller.rb b/plugins/additionals/app/controllers/additionals_assign_to_me_controller.rb index f6ffb33..5569f64 100755 --- a/plugins/additionals/app/controllers/additionals_assign_to_me_controller.rb +++ b/plugins/additionals/app/controllers/additionals_assign_to_me_controller.rb @@ -23,7 +23,7 @@ class AdditionalsAssignToMeController < ApplicationController return redirect_to(issue_path(@issue)) if last_journal.nil? last_journal = @issue.journals.visible.order(:created_on).last - redirect_to "#{issue_path(@issue)}#change-#{last_journal.id}" + redirect_to "#{issue_path @issue}#change-#{last_journal.id}" end private diff --git a/plugins/additionals/app/controllers/additionals_change_status_controller.rb b/plugins/additionals/app/controllers/additionals_change_status_controller.rb index 0e1404e..ed1a481 100755 --- a/plugins/additionals/app/controllers/additionals_change_status_controller.rb +++ b/plugins/additionals/app/controllers/additionals_change_status_controller.rb @@ -27,7 +27,7 @@ class AdditionalsChangeStatusController < ApplicationController return redirect_to(issue_path(@issue)) if last_journal.nil? last_journal = @issue.journals.visible.order(:created_on).last - redirect_to "#{issue_path(@issue)}#change-#{last_journal.id}" + redirect_to "#{issue_path @issue}#change-#{last_journal.id}" end private diff --git a/plugins/additionals/app/controllers/dashboard_async_blocks_controller.rb b/plugins/additionals/app/controllers/dashboard_async_blocks_controller.rb new file mode 100644 index 0000000..d5e7f6a --- /dev/null +++ b/plugins/additionals/app/controllers/dashboard_async_blocks_controller.rb @@ -0,0 +1,96 @@ +require 'open-uri' + +class DashboardAsyncBlocksController < ApplicationController + before_action :find_dashboard + before_action :find_block + + helper :additionals_routes + helper :additionals_queries + helper :additionals_tag + helper :queries + helper :issues + helper :activities + helper :dashboards + + include DashboardsHelper + + rescue_from Query::StatementInvalid, with: :query_statement_invalid + rescue_from StandardError, with: :dashboard_with_invalid_block + + def show + @settings[:sort] = params[:sort] if params[:sort].present? + partial_locals = build_dashboard_partial_locals @block, @block_definition, @settings, @dashboard + + respond_to do |format| + format.js do + render partial: partial_locals[:async][:partial], + content_type: 'text/html', + locals: partial_locals + end + end + end + + # abuse create for query list sort order support + def create + return render_403 if params[:sort].blank? + + partial_locals = build_dashboard_partial_locals @block, @block_definition, @settings, @dashboard + partial_locals[:sort_options] = { sort: params[:sort] } + + respond_to do |format| + format.js do + render partial: 'update_order_by', + locals: partial_locals + end + end + end + + private + + def find_dashboard + @dashboard = Dashboard.find params[:dashboard_id] + raise ::Unauthorized unless @dashboard.visible? + + if @dashboard.dashboard_type == DashboardContentProject::TYPE_NAME && @dashboard.project.nil? + @dashboard.content_project = find_project_by_project_id + else + @project = @dashboard.project + deny_access if @project.present? && !User.current.allowed_to?(:view_project, @project) + end + + @can_edit = @dashboard&.editable? + rescue ActiveRecord::RecordNotFound + render_404 + end + + def find_block + @block = params['block'] + @block_definition = @dashboard.content.find_block @block + + render_404 if @block.blank? + render_403 if @block_definition.blank? + + @settings = @dashboard.layout_settings @block + end + + def find_project_by_project_id + begin + @project = Project.find params[:project_id] + rescue ActiveRecord::RecordNotFound + render_404 + end + deny_access unless User.current.allowed_to?(:view_project, @project) + + @project + end + + def dashboard_with_invalid_block(exception) + logger&.error "Invalid dashboard block for #{@block}: #{exception.message}" + respond_to do |format| + format.html do + render template: 'dashboards/block_error', layout: false + end + format.any { head @status } + end + end +end diff --git a/plugins/additionals/app/controllers/dashboards_controller.rb b/plugins/additionals/app/controllers/dashboards_controller.rb new file mode 100644 index 0000000..4ab51b8 --- /dev/null +++ b/plugins/additionals/app/controllers/dashboards_controller.rb @@ -0,0 +1,217 @@ +class DashboardsController < ApplicationController + menu_item :dashboards + + before_action :find_dashboard, except: %i[index new create] + before_action :find_optional_project, only: %i[new create index] + + accept_rss_auth :index, :show + accept_api_auth :index, :show, :create, :update, :destroy + + rescue_from Query::StatementInvalid, with: :query_statement_invalid + + helper :queries + helper :issues + helper :activities + helper :watchers + helper :additionals_routes + helper :dashboards + helper :additionals_issues + helper :additionals_queries + helper :additionals_tag + + include AdditionalsRoutesHelper + include AdditionalsQueriesHelper + include QueriesHelper + include WatchersHelper + include SortHelper + + def index + case params[:format] + when 'xml', 'json' + @offset, @limit = api_offset_and_limit + else + @limit = per_page_option + end + + scope = Dashboard.visible + @query_count = scope.count + @query_pages = Paginator.new @query_count, @limit, params['page'] + @dashboards = scope.sorted + .limit(@limit) + .offset(@offset) + .to_a + + respond_to do |format| + format.html { render_error status: 406 } + format.api + end + end + + def show + respond_to do |format| + format.html { head 406 } + format.js if request.xhr? + format.api + end + end + + def new + @dashboard = Dashboard.new(project: @project, + author: User.current) + @dashboard.dashboard_type = assign_dashboard_type + @allowed_projects = @dashboard.allowed_target_projects + end + + def create + @dashboard = Dashboard.new(author: User.current) + @dashboard.safe_attributes = params[:dashboard] + @dashboard.dashboard_type = assign_dashboard_type + @dashboard.role_ids = params[:dashboard][:role_ids] if params[:dashboard].present? + + @allowed_projects = @dashboard.allowed_target_projects + + if @dashboard.save + flash[:notice] = l(:notice_successful_create) + + respond_to do |format| + format.html { redirect_to dashboard_link_path(@project, @dashboard) } + format.api { render action: 'show', status: :created, location: dashboard_url(@dashboard, project_id: @project) } + end + else + respond_to do |format| + format.html { render action: 'new' } + format.api { render_validation_errors(@dashboard) } + end + end + end + + def edit + return render_403 unless @dashboard.editable_by?(User.current) + + @allowed_projects = @dashboard.allowed_target_projects + + respond_to do |format| + format.html + format.xml {} + end + end + + def update + return render_403 unless @dashboard.editable_by?(User.current) + + @dashboard.safe_attributes = params[:dashboard] + @dashboard.role_ids = params[:dashboard][:role_ids] if params[:dashboard].present? + + @project = @dashboard.project if @project && @dashboard.project_id.present? && @dashboard.project != @project + @allowed_projects = @dashboard.allowed_target_projects + + if @dashboard.save + flash[:notice] = l(:notice_successful_update) + respond_to do |format| + format.html { redirect_to dashboard_link_path @project, @dashboard } + format.api { head :ok } + end + else + respond_to do |format| + format.html { render action: 'edit' } + format.api { render_validation_errors @dashboard } + end + end + end + + def destroy + return render_403 unless @dashboard.destroyable_by? User.current + + begin + @dashboard.destroy + flash[:notice] = l(:notice_successful_delete) + respond_to do |format| + format.html { redirect_to @project.nil? ? home_path : project_path(@project) } + format.api { head :ok } + end + rescue ActiveRecord::RecordNotDestroyed + flash[:error] = l(:error_remove_db_entry) + redirect_to dashboard_path(@dashboard) + end + end + + def query_statement_invalid(exception) + logger&.error "Query::StatementInvalid: #{exception.message}" + session.delete(additionals_query_session_key('dashboard')) + render_error l(:error_query_statement_invalid) + end + + def update_layout_setting + block_settings = params[:settings] || {} + + block_settings.each do |block, settings| + @dashboard.update_block_settings(block, settings.to_unsafe_hash) + end + @dashboard.save + @updated_blocks = block_settings.keys + end + + # The block is added on top of the page + # params[:block] : id of the block to add + def add_block + @block = params[:block] + if @dashboard.add_block @block + @dashboard.save + respond_to do |format| + format.html { redirect_to dashboard_link_path(@project, @dashboard) } + format.js + end + else + render_error status: 422 + end + end + + # params[:block] : id of the block to remove + def remove_block + @block = params[:block] + @dashboard.remove_block @block + @dashboard.save + respond_to do |format| + format.html { redirect_to dashboard_link_path(@project, @dashboard) } + format.js + end + end + + # Change blocks order + # params[:group] : group to order (top, left or right) + # params[:blocks] : array of block ids of the group + def order_blocks + @dashboard.order_blocks params[:group], params[:blocks] + @dashboard.save + head 200 + end + + private + + def assign_dashboard_type + if params['dashboard_type'].present? + params['dashboard_type'] + elsif params['dashboard'].present? && params['dashboard']['dashboard_type'].present? + params['dashboard']['dashboard_type'] + elsif @project.nil? + DashboardContentWelcome::TYPE_NAME + else + DashboardContentProject::TYPE_NAME + end + end + + def find_dashboard + @dashboard = Dashboard.find(params[:id]) + raise ::Unauthorized unless @dashboard.visible? + + if @dashboard.dashboard_type == DashboardContentProject::TYPE_NAME && @dashboard.project.nil? + @dashboard.content_project = find_project_by_project_id + else + @project = @dashboard.project + end + + @can_edit = @dashboard&.editable? + rescue ActiveRecord::RecordNotFound + render_404 + end +end diff --git a/plugins/additionals/app/helpers/additionals_chartjs_helper.rb b/plugins/additionals/app/helpers/additionals_chartjs_helper.rb new file mode 100644 index 0000000..6eee28b --- /dev/null +++ b/plugins/additionals/app/helpers/additionals_chartjs_helper.rb @@ -0,0 +1,12 @@ +module AdditionalsChartjsHelper + def chartjs_colorschemes_info_url + link_to(l(:label_chartjs_colorscheme_info), + 'https://nagix.github.io/chartjs-plugin-colorschemes/colorchart.html', + class: 'external') + end + + def select_options_for_chartjs_colorscheme(selected) + data = YAML.safe_load(ERB.new(IO.read(Rails.root.join('plugins/additionals/config/colorschemes.yml'))).result) || {} + grouped_options_for_select(data, selected) + end +end diff --git a/plugins/additionals/app/helpers/additionals_clipboardjs_helper.rb b/plugins/additionals/app/helpers/additionals_clipboardjs_helper.rb new file mode 100644 index 0000000..61fe55c --- /dev/null +++ b/plugins/additionals/app/helpers/additionals_clipboardjs_helper.rb @@ -0,0 +1,25 @@ +module AdditionalsClipboardjsHelper + def clipboardjs_button_for(target, clipboard_text_from_button = nil) + render_clipboardjs_button(target, clipboard_text_from_button) + render_clipboardjs_javascript(target) + end + + private + + def render_clipboardjs_button(target, clipboard_text_from_button) + data = { 'clipboard-target' => "##{target}", + 'label-copied' => l(:label_copied_to_clipboard), + 'label-to-copy' => l(:label_copy_to_clipboard) } + + data['clipboard-text'] = clipboard_text_from_button if clipboard_text_from_button.present? + + tag.button id: "zc_#{target}", + class: 'clipboard_button far fa-copy', + type: 'button', + title: l(:label_copy_to_clipboard), + data: data + end + + def render_clipboardjs_javascript(target) + javascript_tag("setClipboardJS('#zc_#{target}');") + end +end diff --git a/plugins/additionals/app/helpers/additionals_custom_fields_helper.rb b/plugins/additionals/app/helpers/additionals_custom_fields_helper.rb new file mode 100644 index 0000000..d554b9d --- /dev/null +++ b/plugins/additionals/app/helpers/additionals_custom_fields_helper.rb @@ -0,0 +1,5 @@ +module AdditionalsCustomFieldsHelper + def custom_fields_with_full_with_layout + ['IssueCustomField'] + end +end diff --git a/plugins/additionals/app/helpers/additionals_fontawesome_helper.rb b/plugins/additionals/app/helpers/additionals_fontawesome_helper.rb index 39dc0e5..fe03edd 100755 --- a/plugins/additionals/app/helpers/additionals_fontawesome_helper.rb +++ b/plugins/additionals/app/helpers/additionals_fontawesome_helper.rb @@ -1,11 +1,4 @@ module AdditionalsFontawesomeHelper - def fontawesome_info_url - s = [] - s << l(:label_set_icon_from) - s << link_to('https://fontawesome.com/icons?m=free', 'https://fontawesome.com/icons?m=free', class: 'external') - safe_join(s, ' ') - end - # name = TYPE-FA_NAME, eg. fas_car # fas_cloud-upload-alt # far_id-card @@ -15,13 +8,13 @@ module AdditionalsFontawesomeHelper # post_text # title def font_awesome_icon(name, options = {}) - info = AdditionalsFontAwesome.value_info(name) + info = AdditionalsFontAwesome.value_info name return '' if info.blank? post_text = '' - options['aria-hidden'] = 'true' + options[:'aria-hidden'] = 'true' options[:class] = if options[:class].present? - info[:classes] + ' ' + options[:class] + "#{info[:classes]} #{options[:class]}" else info[:classes] end @@ -30,17 +23,68 @@ module AdditionalsFontawesomeHelper if options[:pre_text].present? s << options[:pre_text] s << ' ' - options.delete(:pre_text) + options.delete :pre_text end if options[:post_text].present? post_text = options[:post_text] - options.delete(:post_text) + options.delete :post_text end - s << content_tag('span', '', options) + s << tag.span(options) if post_text.present? s << ' ' s << post_text end - safe_join(s) + safe_join s + end + + def additionals_fontawesome_select(form, selected, options = {}) + options[:include_blank] ||= true unless options[:required] + html_options = {} + + additionals_fontawesome_add_selected selected + + name, options = Additionals.hash_remove_with_default(:name, options, :icon) + loader, options = Additionals.hash_remove_with_default(:loader, options, true) + html_options[:class], options = Additionals.hash_remove_with_default(:class, options, 'select2-fontawesome-field') + html_options[:style], options = Additionals.hash_remove_with_default(:style, options) + + s = [] + s << form.select(name, + options_for_select(AdditionalsFontAwesome.active_option_for_select(selected), selected), + options, + html_options) + + s << additionals_fontawesome_loader(options, html_options) if loader + + safe_join s + end + + def additionals_fontawesome_add_selected(selected) + @selected_store ||= [] + return if selected.blank? + + @selected_store << selected + end + + def additionals_fontawesome_default_select_width + '250px' + end + + def additionals_fontawesome_loader(options, html_options = {}) + html_options[:class] ||= 'select2-fontawesome-field' + options[:template_selection] = 'formatFontawesomeText' + options[:template_result] = 'formatFontawesomeText' + if options[:include_blank] + options[:placeholder] ||= l(:label_disabled) + options[:allow_clear] ||= true + end + options[:width] = additionals_fontawesome_default_select_width + + render(layout: false, + partial: 'additionals/select2_ajax_call.js', + formats: [:js], + locals: { field_class: html_options[:class], + ajax_url: fontawesome_auto_completes_path(selected: @selected_store.join(',')), + options: options }) end end diff --git a/plugins/additionals/app/helpers/additionals_issues_helper.rb b/plugins/additionals/app/helpers/additionals_issues_helper.rb index f0b0962..e184394 100755 --- a/plugins/additionals/app/helpers/additionals_issues_helper.rb +++ b/plugins/additionals/app/helpers/additionals_issues_helper.rb @@ -1,18 +1,29 @@ module AdditionalsIssuesHelper - def issue_author_options_for_select(project, issue = nil) - authors = project.users.sorted + def author_options_for_select(project, entity = nil, permission = nil) + scope = project.present? ? project.users.visible : User.active.visible + scope = scope.with_permission(permission, project) unless permission.nil? + authors = scope.sorted.to_a + + unless entity.nil? + current_author_found = authors.detect { |u| u.id == entity.author_id_was } + if current_author_found.blank? + current_author = User.find_by id: entity.author_id_was + authors << current_author if current_author + end + end + s = [] return s unless authors.any? - s << content_tag('option', "<< #{l(:label_me)} >>", value: User.current.id) if authors.include?(User.current) + s << tag.option("<< #{l :label_me} >>", value: User.current.id) if authors.include?(User.current) - if issue.nil? + if entity.nil? s << options_from_collection_for_select(authors, 'id', 'name') else - s << content_tag('option', issue.author, value: issue.author_id, selected: true) if issue.author && !authors.include?(issue.author) - s << options_from_collection_for_select(authors, 'id', 'name', issue.author_id) + s << tag.option(entity.author, value: entity.author_id, selected: true) if entity.author && authors.exclude?(entity.author) + s << options_from_collection_for_select(authors, 'id', 'name', entity.author_id) end - safe_join(s) + safe_join s end def show_issue_change_author?(issue) diff --git a/plugins/additionals/app/helpers/additionals_journals_helper.rb b/plugins/additionals/app/helpers/additionals_journals_helper.rb new file mode 100644 index 0000000..4549ef6 --- /dev/null +++ b/plugins/additionals/app/helpers/additionals_journals_helper.rb @@ -0,0 +1,159 @@ +module AdditionalsJournalsHelper + MultipleValuesDetail = Struct.new(:property, :prop_key, :custom_field, :old_value, :value) + + # Returns the textual representation of a journal details + # as an array of strings + def entity_details_to_strings(entity, details, options = {}) + entity_type = entity.model_name.param_key + show_detail_method = "#{entity_type}_show_detail" + options[:only_path] = options[:only_path] != false + no_html = options.delete(:no_html) + strings = [] + values_by_field = {} + + details.each do |detail| + if detail.property == 'cf' + field = detail.custom_field + if field&.multiple? + values_by_field[field] ||= { added: [], deleted: [] } + values_by_field[field][:deleted] << detail.old_value if detail.old_value + values_by_field[field][:added] << detail.value if detail.value + next + end + end + strings << send(show_detail_method, detail, no_html, options) + end + + if values_by_field.present? + values_by_field.each do |field, changes| + if changes[:added].any? + detail = MultipleValuesDetail.new('cf', field.id.to_s, field) + detail.value = changes[:added] + strings << send(show_detail_method, detail, no_html, options) + end + next unless changes[:deleted].any? + + detail = MultipleValuesDetail.new('cf', field.id.to_s, field) + detail.old_value = changes[:deleted] + strings << send(show_detail_method, detail, no_html, options) + end + end + strings + end + + # taken from Redmine 4 + # Returns the action links for an issue journal + def render_entity_journal_actions(entity, journal) + return '' unless journal.notes.present? && journal.editable_by?(User.current) + + entity_type = entity.model_name.param_key + + safe_join [link_to(l(:button_edit), + send("edit_#{entity_type}_journal_path", journal), + remote: true, + method: 'get', + title: l(:button_edit), + class: 'icon-only icon-edit'), + link_to(l(:button_delete), + send("#{entity_type}_journal_path", journal, journal: { notes: '' }), + remote: true, + method: 'put', data: { confirm: l(:text_are_you_sure) }, + title: l(:button_delete), + class: 'icon-only icon-del')], ' ' + end + + # Returns the textual representation of a single journal detail + # rubocop: disable Rails/OutputSafety + def entity_show_detail(entity, detail, no_html = false, options = {}) # rubocop:disable Style/OptionalBooleanParameter: + multiple = false + no_detail = false + show_diff = false + label = nil + diff_url_method = "diff_#{entity.name.underscore}_journal_url" + entity_prop = entity_show_detail_prop detail, options + + if entity_prop.present? + label = entity_prop[:label] if entity_prop.key? :label + value = entity_prop[:value] if entity_prop.key? :value + old_value = entity_prop[:old_value] if entity_prop.key? :old_value + show_diff = entity_prop[:show_diff] if entity_prop.key? :show_diff + no_detail = entity_prop[:no_detail] if entity_prop.key? :no_detail + end + + if label || show_diff + unless no_html + label = tag.strong(label) + old_value = tag.i(old_value) if detail.old_value + old_value = tag.del(old_value) if detail.old_value && detail.value.blank? + value = tag.i(value) if value + end + + html = + if no_detail + l(:text_journal_changed_no_detail, label: label) + elsif show_diff + s = l(:text_journal_changed_no_detail, label: label) + unless no_html + diff_link = link_to l(:label_diff), + send(diff_url_method, + detail.journal_id, + detail_id: detail.id, + only_path: options[:only_path]), + title: l(:label_view_diff) + s << " (#{diff_link})" + end + s.html_safe + elsif detail.value.present? + if detail.old_value.present? + l(:text_journal_changed, label: label, old: old_value, new: value) + elsif multiple + l(:text_journal_added, label: label, value: value) + else + l(:text_journal_set_to, label: label, value: value) + end + else + l(:text_journal_deleted, label: label, old: old_value).html_safe + end + html.html_safe + else + # default implementation for journal detail rendering + show_detail detail, no_html, options + end + end + # rubocop: enable Rails/OutputSafety + + private + + def entity_show_detail_prop(detail, options) + return options[:entity_prop] if options.key? :entity_prop + return unless detail.property == 'cf' + + custom_field = detail.custom_field + return unless custom_field + + return { show_diff: true, label: l(:field_description) } if custom_field.format.class.change_as_diff + + case custom_field.format.name + when 'project_relation' + prop = { label: custom_field.name } + project = Project.visible.where(id: detail.value).first if detail.value.present? + old_project = Project.visible.where(id: detail.old_value).first if detail.old_value.present? + prop[:value] = link_to_project(project) if project.present? + prop[:old_value] = link_to_project(old_project) if old_project.present? + when 'db_entry' + prop = { label: custom_field.name } + db_entry = DbEntry.visible.where(id: detail.value).first if detail.value.present? + old_db_entry = DbEntry.visible.where(id: detail.old_value).first if detail.old_value.present? + prop[:value] = link_to(db_entry.name, db_entry_url(db_entry)) if db_entry.present? + prop[:old_value] = link_to(old_db_entry.name, db_entry_url(old_db_entry)) if old_db_entry.present? + when 'password' + prop = { label: custom_field.name } + password = Password.visible.where(id: detail.value).first if detail.value.present? && defined?(Password) + old_password = Password.visible.where(id: detail.old_value).first if detail.old_value.present? && defined?(Password) + prop[:value] = link_to(password.name, password_url(password)) if password.present? + prop[:old_value] = link_to(old_password.name, password_url(old_password)) if old_password.present? + end + + prop + end +end diff --git a/plugins/additionals/app/helpers/additionals_menu_helper.rb b/plugins/additionals/app/helpers/additionals_menu_helper.rb index cc9859f..fabdf9f 100755 --- a/plugins/additionals/app/helpers/additionals_menu_helper.rb +++ b/plugins/additionals/app/helpers/additionals_menu_helper.rb @@ -1,28 +1,37 @@ module AdditionalsMenuHelper def additionals_top_menu_setup - return unless User.current.try(:hrm_user_type_id).nil? + return if Redmine::Plugin.installed? 'redmine_hrm' - if Additionals.setting?(:remove_mypage) + if Additionals.setting? :remove_mypage Redmine::MenuManager.map(:top_menu).delete(:my_page) if Redmine::MenuManager.map(:top_menu).exists?(:my_page) else - handle_top_menu_item(:my_page, url: my_page_path, after: :home, if: proc { User.current.logged? }) + handle_top_menu_item(:my_page, { url: my_page_path, after: :home, if: proc { User.current.logged? } }) end - if Additionals.setting?(:remove_help) + if Additionals.setting? :remove_help Redmine::MenuManager.map(:top_menu).delete(:help) if Redmine::MenuManager.map(:top_menu).exists?(:help) elsif User.current.logged? - handle_top_menu_item(:help, url: '#', symbol: 'fas_question', last: true) + handle_top_submenu_item :help, url: '#', symbol: 'fas_question', last: true @additionals_help_items = additionals_help_menu_items else - handle_top_menu_item(:help, url: Redmine::Info.help_url, symbol: 'fas_question', last: true) + handle_top_menu_item :help, url: Redmine::Info.help_url, symbol: 'fas_question', last: true end end - def handle_top_menu_item(menu_name, item) + def handle_top_submenu_item(menu_name, item) + handle_top_menu_item menu_name, item, with_submenu: true + end + + def handle_top_menu_item(menu_name, item, with_submenu: false) Redmine::MenuManager.map(:top_menu).delete(menu_name.to_sym) if Redmine::MenuManager.map(:top_menu).exists?(menu_name.to_sym) html_options = {} - html_options[:class] = 'external' if item[:url].include? '://' + + css_classes = [] + css_classes << 'top-submenu' if with_submenu + css_classes << 'external' if item[:url].include? '://' + html_options[:class] = css_classes.join(' ') if css_classes.present? + html_options[:title] = item[:title] if item[:title].present? menu_options = { parent: item[:parent].present? ? item[:parent].to_sym : nil, @@ -48,7 +57,7 @@ module AdditionalsMenuHelper menu_options[:before] = :help end - Redmine::MenuManager.map(:top_menu).push(menu_name, item[:url], menu_options) + Redmine::MenuManager.map(:top_menu).push menu_name, item[:url], menu_options end def render_custom_top_menu_item @@ -56,14 +65,15 @@ module AdditionalsMenuHelper return if items.empty? user_roles = Role.givable - .joins(:members).where(members: { user_id: User.current.id }) - .joins(members: :project).where(projects: { status: Project::STATUS_ACTIVE }) + .joins(members: :project) + .where(members: { user_id: User.current.id }, + projects: { status: Project::STATUS_ACTIVE }) .distinct .reorder(nil) - .pluck(:id) + .ids items.each do |item| - additionals_custom_top_menu_item(item, user_roles) + additionals_custom_top_menu_item item, user_roles end end @@ -72,10 +82,10 @@ module AdditionalsMenuHelper Additionals::MAX_CUSTOM_MENU_ITEMS.times do |num| menu_name = "custom_menu#{num}" item = { menu_name: menu_name.to_sym, - url: Additionals.settings[menu_name + '_url'], - name: Additionals.settings[menu_name + '_name'], - title: Additionals.settings[menu_name + '_title'], - roles: Additionals.settings[menu_name + '_roles'] } + url: Additionals.setting("#{menu_name}_url"), + name: Additionals.setting("#{menu_name}_name"), + title: Additionals.setting("#{menu_name}_title"), + roles: Additionals.setting("#{menu_name}_roles") } if item[:name].present? && item[:url].present? && item[:roles].present? items << item @@ -110,7 +120,7 @@ module AdditionalsMenuHelper end if show_entry - handle_top_menu_item(item[:menu_name], item) + handle_top_menu_item item[:menu_name], item elsif Redmine::MenuManager.map(:top_menu).exists?(item[:menu_name]) Redmine::MenuManager.map(:top_menu).delete(item[:menu_name]) end @@ -118,12 +128,16 @@ module AdditionalsMenuHelper def addtionals_help_plugin_items user_items = [{ title: 'Redmine Guide', url: Redmine::Info.help_url }, - { title: "Redmine #{l(:label_macro_plural)}", url: additionals_macros_path }] + { title: "Redmine #{l :label_macro_plural}", url: additionals_macros_path }] - admin_items = [{ title: 'Additionals', url: 'https://additionals.readthedocs.io/en/latest/manual/', manual: true }, - { title: 'Redmine Changelog', url: 'https://www.redmine.org/projects/redmine/wiki/Changelog_3_4' }, - { title: 'Redmine Upgrade', url: 'https://www.redmine.org/projects/redmine/wiki/RedmineUpgrade' }, - { title: 'Redmine Security Advisories', url: 'https://www.redmine.org/projects/redmine/wiki/Security_Advisories' }] + admin_items = [{ title: 'Additionals', + url: 'https://additionals.readthedocs.io/en/latest/manual/', manual: true }, + { title: 'Redmine Changelog', + url: "https://www.redmine.org/projects/redmine/wiki/Changelog_#{Redmine::VERSION::MAJOR}_#{Redmine::VERSION::MINOR}" }, + { title: 'Redmine Upgrade', + url: 'https://www.redmine.org/projects/redmine/wiki/RedmineUpgrade' }, + { title: 'Redmine Security Advisories', + url: 'https://www.redmine.org/projects/redmine/wiki/Security_Advisories' }] Redmine::Plugin.all.each do |plugin| next if plugin.id == :additionals @@ -145,7 +159,7 @@ module AdditionalsMenuHelper plugin_item.each do |temp_item| u_items = if !temp_item[:manual].nil? && temp_item[:manual] - { title: "#{temp_item[:title]} #{l(:label_help_manual)}", url: temp_item[:url] } + { title: "#{temp_item[:title]} #{l :label_help_manual}", url: temp_item[:url] } else { title: temp_item[:title], url: temp_item[:url] } end @@ -173,18 +187,17 @@ module AdditionalsMenuHelper s = [] pages.each_with_index do |item, idx| s << if item[:title] == '-' - content_tag(:li, tag(:hr)) + tag.li tag.hr else - html_options = { class: 'help_item_' + idx.to_s } + html_options = { class: "help_item_#{idx}" } if item[:url].include? '://' html_options[:class] << ' external' html_options[:target] = '_blank' end - content_tag(:li, - link_to(item[:title], item[:url], html_options)) + tag.li(link_to(item[:title], item[:url], html_options)) end end - safe_join(s) + safe_join s end # Plugin help items definition for plugins, diff --git a/plugins/additionals/app/helpers/additionals_projects_helper.rb b/plugins/additionals/app/helpers/additionals_projects_helper.rb new file mode 100644 index 0000000..5d44081 --- /dev/null +++ b/plugins/additionals/app/helpers/additionals_projects_helper.rb @@ -0,0 +1,8 @@ +module AdditionalsProjectsHelper + def project_overview_name(_project, dashboard = nil) + name = [l(:label_overview)] + name << dashboard.name if dashboard.present? && (dashboard.always_expose? || !dashboard.system_default) + + safe_join name, Additionals::LIST_SEPARATOR + end +end diff --git a/plugins/additionals/app/helpers/additionals_queries_helper.rb b/plugins/additionals/app/helpers/additionals_queries_helper.rb index 79c4c3a..3b0c4df 100755 --- a/plugins/additionals/app/helpers/additionals_queries_helper.rb +++ b/plugins/additionals/app/helpers/additionals_queries_helper.rb @@ -52,9 +52,9 @@ module AdditionalsQueriesHelper end def additionals_load_query_id(query_class, session_key, query_id, options, object_type) - cond = 'project_id IS NULL' - cond << " OR project_id = #{@project.id}" if @project - @query = query_class.where(cond).find(query_id) + scope = query_class.where(project_id: nil) + scope = scope.or(query_class.where(project_id: @project.id)) if @project + @query = scope.find(query_id) raise ::Unauthorized unless @query.visible? @query.project = @project @@ -72,19 +72,20 @@ module AdditionalsQueriesHelper end def additionals_query_cache_key(object_type) - project_id = @project.nil? ? 0 : @project.id + project_id = @project ? @project.id : 0 "#{object_type}_query_data_#{session.id}_#{project_id}" end - def additionals_select2_search_users(where_filter = '', where_params = {}) + def additionals_select2_search_users(options = {}) q = params[:q].to_s.strip exclude_id = params[:user_id].to_i scope = User.active.where(type: 'User') - scope = scope.where.not(id: exclude_id) if exclude_id > 0 - scope = scope.where(where_filter, where_params) if where_filter.present? - scope = scope.like(q) if q.present? + scope = scope.visible unless options[:all_visible] + scope = scope.where.not(id: exclude_id) if exclude_id.positive? + scope = scope.where(options[:where_filter], options[:where_params]) if options[:where_filter] + q.split(' ').map { |search_string| scope = scope.like(search_string) } if q.present? scope = scope.order(last_login_on: :desc) - .limit(params[:limit] || Additionals::SELECT2_INIT_ENTRIES) + .limit(Additionals::SELECT2_INIT_ENTRIES) @users = scope.to_a.sort! { |x, y| x.name <=> y.name } render layout: false, partial: 'auto_completes/additionals_users' end @@ -97,9 +98,10 @@ module AdditionalsQueriesHelper query.columns end - stream = StringIO.new('') - export_to_xlsx(items, columns, filename: stream) - stream.string + options[:filename] = StringIO.new('') + + export_to_xlsx(items, columns, options) + options[:filename].string end def additionals_result_to_xlsx(items, columns, options = {}) @@ -161,10 +163,18 @@ module AdditionalsQueriesHelper columns.each_with_index do |c, column_index| value = csv_content(c, line) if c.name == :id # ID - link = url_for(controller: line.class.name.underscore.pluralize, action: 'show', id: line.id) - worksheet.write(line_index + 1, column_index, link, hyperlink_format, value) + if options[:no_id_link].blank? + link = url_for(controller: line.class.name.underscore.pluralize, action: 'show', id: line.id) + worksheet.write(line_index + 1, column_index, link, hyperlink_format, value) + else + # id without link + worksheet.write(line_index + 1, + column_index, + value, + workbook.add_format(xlsx_cell_format(:cell, value, line_index))) + end elsif xlsx_hyperlink_cell?(value) - worksheet.write(line_index + 1, column_index, value, hyperlink_format, value) + worksheet.write(line_index + 1, column_index, value[0..254], hyperlink_format, value) elsif !c.inline? # block column can be multiline strings value.gsub!("\r\n", "\n") @@ -188,7 +198,7 @@ module AdditionalsQueriesHelper value_str = value.to_s # 1.1: margin - width = (value_str.length + value_str.chars.reject(&:ascii_only?).length) * 1.1 + 1 + width = (value_str.length + value_str.chars.count { |e| !e.ascii_only? }) * 1.1 + 1 # 30: max width width > 30 ? 30 : width end @@ -206,7 +216,7 @@ module AdditionalsQueriesHelper format[:bg_color] = 'silver' unless index.even? else format[:bg_color] = 'silver' unless index.even? - format[:color] = 'red' if value.is_a?(Numeric) && value < 0 + format[:color] = 'red' if value.is_a?(Numeric) && value.negative? end format @@ -214,13 +224,13 @@ module AdditionalsQueriesHelper def xlsx_hyperlink_cell?(token) # Match http, https or ftp URL - if token =~ %r{\A[fh]tt?ps?://} + if %r{\A[fh]tt?ps?://}.match?(token) true # Match mailto: elsif token.present? && token.start_with?('mailto:') true # Match internal or external sheet link - elsif token =~ /\A(?:in|ex)ternal:/ + elsif /\A(?:in|ex)ternal:/.match?(token) true end end @@ -229,7 +239,7 @@ module AdditionalsQueriesHelper # columns in ignored_column_names are skipped (names as symbols) # TODO: this is a temporary fix and should be removed # after https://www.redmine.org/issues/29830 is in Redmine core. - def query_as_hidden_field_tags(query, ignored_column_names = nil) + def query_as_hidden_field_tags(query) tags = hidden_field_tag('set_filter', '1', id: nil) if query.filters.present? @@ -243,8 +253,10 @@ module AdditionalsQueriesHelper else tags << hidden_field_tag('f[]', '', id: nil) end + + ignored_block_columns = query.block_columns.map(&:name) query.columns.each do |column| - next if ignored_column_names.present? && ignored_column_names.include?(column.name) + next if ignored_block_columns.include?(column.name) tags << hidden_field_tag('c[]', column.name, id: nil) end @@ -258,4 +270,11 @@ module AdditionalsQueriesHelper tags end + + def render_query_group_view(query, locals = {}) + return if locals[:group_name].blank? + + render partial: 'queries/additionals_group_view', + locals: { query: query }.merge(locals) + end end diff --git a/plugins/additionals/app/helpers/additionals_routes_helper.rb b/plugins/additionals/app/helpers/additionals_routes_helper.rb new file mode 100644 index 0000000..9d2de5b --- /dev/null +++ b/plugins/additionals/app/helpers/additionals_routes_helper.rb @@ -0,0 +1,82 @@ +module AdditionalsRoutesHelper + def _dashboards_path(project, *args) + if project + project_dashboards_path(project, *args) + else + dashboards_path(*args) + end + end + + def _dashboard_path(project, *args) + if project + project_dashboard_path(project, *args) + else + dashboard_path(*args) + end + end + + def _dashboard_async_blocks_path(project, *args) + if project + project_dashboard_async_blocks_path(project, *args) + else + dashboard_async_blocks_path(*args) + end + end + + def _edit_dashboard_path(project, *args) + if project + edit_project_dashboard_path(project, *args) + else + edit_dashboard_path(*args) + end + end + + def _new_dashboard_path(project, *args) + if project + new_project_dashboard_path(project, *args) + else + new_dashboard_path(*args) + end + end + + def _update_layout_setting_dashboard_path(project, *args) + if project + update_layout_setting_project_dashboard_path(project, *args) + else + update_layout_setting_dashboard_path(*args) + end + end + + def _add_block_dashboard_path(project, *args) + if project + add_block_project_dashboard_path(project, *args) + else + add_block_dashboard_path(*args) + end + end + + def _remove_block_dashboard_path(project, *args) + if project + remove_block_project_dashboard_path(project, *args) + else + remove_block_dashboard_path(*args) + end + end + + def _order_blocks_dashboard_path(project, *args) + if project + order_blocks_project_dashboard_path(project, *args) + else + order_blocks_dashboard_path(*args) + end + end + + def dashboard_link_path(project, dashboard, options = {}) + options[:dashboard_id] = dashboard.id + if dashboard.dashboard_type == DashboardContentProject::TYPE_NAME + project_path project, options + else + home_path options + end + end +end diff --git a/plugins/additionals/app/helpers/additionals_select2_helper.rb b/plugins/additionals/app/helpers/additionals_select2_helper.rb new file mode 100644 index 0000000..5ad1931 --- /dev/null +++ b/plugins/additionals/app/helpers/additionals_select2_helper.rb @@ -0,0 +1,13 @@ +module AdditionalsSelect2Helper + def additionals_select2_tag(name, option_tags = nil, options = {}) + s = select_tag(name, option_tags, options) + s << hidden_field_tag("#{name}[]", '') if options[:multiple] && options.fetch(:include_hidden, true) + + s + javascript_tag("select2Tag('#{sanitize_to_id name}', #{options.to_json});") + end + + # Transforms select filters of +type+ fields into select2 + def additionals_transform_to_select2(type, options = {}) + javascript_tag("setSelect2Filter('#{type}', #{options.to_json});") unless type.empty? + end +end diff --git a/plugins/additionals/app/helpers/additionals_settings_helper.rb b/plugins/additionals/app/helpers/additionals_settings_helper.rb new file mode 100644 index 0000000..d3e01c4 --- /dev/null +++ b/plugins/additionals/app/helpers/additionals_settings_helper.rb @@ -0,0 +1,59 @@ +module AdditionalsSettingsHelper + def additionals_settings_tabs + tabs = [{ name: 'general', partial: 'additionals/settings/general', label: :label_general }, + { name: 'wiki', partial: 'additionals/settings/wiki', label: :label_wiki }, + { name: 'macros', partial: 'additionals/settings/macros', label: :label_macro_plural }, + { name: 'rules', partial: 'additionals/settings/issues', label: :label_issue_plural }, + { name: 'users', partial: 'additionals/settings/users', label: :label_user_plural }, + { name: 'web', partial: 'additionals/settings/web_apis', label: :label_web_apis }] + + unless Redmine::Plugin.installed? 'redmine_hrm' + tabs << { name: 'menu', partial: 'additionals/settings/menu', label: :label_settings_menu } + end + + tabs + end + + def additionals_settings_checkbox(name, options = {}) + label_title = options.delete(:label).presence || l("label_#{name}") + value = options.delete :value + value_is_bool = options.delete :value_is_bool + custom_value = if value.nil? + value = 1 + false + else + value = 1 if value_is_bool + true + end + + checked = if custom_value && !value_is_bool + @settings[name] + else + Additionals.true? @settings[name] + end + + s = [label_tag("settings[#{name}]", label_title)] + s << hidden_field_tag("settings[#{name}]", 0, id: nil) if !custom_value || value_is_bool + s << check_box_tag("settings[#{name}]", value, checked, options) + safe_join s + end + + def additionals_settings_textfield(name, options = {}) + label_title = options.delete(:label).presence || l("label_#{name}") + value = options.delete(:value).presence || @settings[name] + + safe_join [label_tag("settings[#{name}]", label_title), + text_field_tag("settings[#{name}]", value, options)] + end + + def additionals_settings_textarea(name, options = {}) + label_title = options.delete(:label).presence || l("label_#{name}") + value = options.delete(:value).presence || @settings[name] + + options[:class] = 'wiki-edit' unless options.key?(:class) + options[:rows] = addtionals_textarea_cols(value) unless options.key?(:rows) + + safe_join [label_tag("settings[#{name}]", label_title), + text_area_tag("settings[#{name}]", value, options)] + end +end diff --git a/plugins/additionals/app/helpers/additionals_tag_helper.rb b/plugins/additionals/app/helpers/additionals_tag_helper.rb index 1e2c80d..6c5c8d0 100755 --- a/plugins/additionals/app/helpers/additionals_tag_helper.rb +++ b/plugins/additionals/app/helpers/additionals_tag_helper.rb @@ -1,16 +1,6 @@ require 'digest/md5' module AdditionalsTagHelper - # deprecated: this will removed after a while - def render_additionals_tags_list(tags, options = {}) - additionals_tag_cloud(tags, options) - end - - # deprecated: this will removed after a while - def render_additionals_tag_link_line(tag_list) - additionals_tag_links(tag_list) - end - def additionals_tag_cloud(tags, options = {}) return if tags.blank? @@ -31,7 +21,7 @@ module AdditionalsTagHelper ' ' end - content_tag(:div, safe_join(s, sep), class: 'tags') + tag.div safe_join(s, sep), class: 'tags' end # plain list of tags @@ -54,33 +44,31 @@ module AdditionalsTagHelper end safe_join(tag_list.map do |tag| - additionals_tag_link(tag, options) + additionals_tag_link tag, options end, sep) end - def additionals_tag_link(tag, options = {}) + def additionals_tag_link(tag_object, options = {}) tag_name = [] - tag_name << tag.name + tag_name << tag_object.name unless options[:tags_without_color] - tag_bg_color = additionals_tag_color(tag.name) - tag_fg_color = additionals_tag_fg_color(tag_bg_color) + tag_bg_color = additionals_tag_color tag_object.name + tag_fg_color = additionals_tag_fg_color tag_bg_color tag_style = "background-color: #{tag_bg_color}; color: #{tag_fg_color}" end - tag_name << content_tag('span', "(#{tag.count})", class: 'tag-count') if options[:show_count] + tag_name << tag.span("(#{tag_object.count})", class: 'tag-count') if options[:show_count] if options[:tags_without_color] - content_tag('span', - link_to(safe_join(tag_name), additionals_tag_url(tag.name, options)), - class: 'tag-label') + tag.span link_to(safe_join(tag_name), additionals_tag_url(tag_object.name, options)), + class: 'tag-label' else - content_tag('span', - link_to(safe_join(tag_name), - additionals_tag_url(tag.name, options), - style: tag_style), - class: 'additionals-tag-label-color', - style: tag_style) + tag.span link_to(safe_join(tag_name), + additionals_tag_url(tag_object.name, options), + style: tag_style), + class: 'additionals-tag-label-color', + style: tag_style end end @@ -90,7 +78,7 @@ module AdditionalsTagHelper { controller: options[:tag_controller].presence || controller_name, action: action, set_filter: 1, - project_id: @project, + project_id: options[:project], fields: [:tags], values: { tags: [tag_name] }, operators: { tags: '=' } } diff --git a/plugins/additionals/app/helpers/dashboards_helper.rb b/plugins/additionals/app/helpers/dashboards_helper.rb new file mode 100644 index 0000000..17a54a5 --- /dev/null +++ b/plugins/additionals/app/helpers/dashboards_helper.rb @@ -0,0 +1,459 @@ +module DashboardsHelper + def dashboard_sidebar?(dashboard, params) + if params['enable_sidebar'].blank? + if dashboard.blank? + # defaults without dashboard + !@project.nil? + else + dashboard.enable_sidebar? + end + else + Additionals.true? params['enable_sidebar'] + end + end + + def welcome_overview_name(dashboard = nil) + name = [l(:label_home)] + name << dashboard.name if dashboard&.always_expose? || dashboard.present? && !dashboard.system_default? + + safe_join name, Additionals::LIST_SEPARATOR + end + + def dashboard_css_classes(dashboard) + classes = ['dashboard', dashboard.dashboard_type.underscore, "dashboard-#{dashboard.id}"] + safe_join classes, ' ' + end + + def sidebar_dashboards(dashboard, project = nil, user = nil) + user ||= User.current + scope = Dashboard.visible.includes([:author]) + + scope = if project.present? + scope = scope.project_only + scope.where(project_id: project.id) + .or(scope.where(system_default: true) + .where(project_id: nil)) + .or(scope.where(author_id: user.id) + .where(project_id: nil)) + else + scope.where dashboard_type: dashboard.dashboard_type + end + + scope.sorted.to_a + end + + def render_dashboard_actionlist(active_dashboard, project = nil) + dashboards = sidebar_dashboards active_dashboard, project + base_css = 'icon icon-dashboard' + out = [] + + dashboards.select!(&:public?) unless User.current.allowed_to? :save_dashboards, project, global: true + dashboards.each do |dashboard| + css_class = base_css + dashboard_name = "#{l :label_dashboard}: #{dashboard.name}" + out << if dashboard.id == active_dashboard.id + link_to dashboard_name, '#', + onclick: 'return false;', + class: "#{base_css} disabled" + else + dashboard_link dashboard, project, + class: css_class, + title: l(:label_change_to_dashboard), + name: dashboard_name + end + end + + safe_join out + end + + def render_sidebar_dashboards(dashboard, project = nil) + dashboards = sidebar_dashboards dashboard, project + out = [dashboard_links(l(:label_my_dashboard_plural), + dashboard, + User.current.allowed_to?(:save_dashboards, project, global: true) ? dashboards.select(&:private?) : [], + project), + dashboard_links(l(:label_shared_dashboard_plural), + dashboard, + dashboards.select(&:public?), + project)] + + out << dashboard_info(dashboard) if dashboard.always_expose? || !dashboard.system_default + + safe_join out + end + + def dashboard_info(dashboard) + tag.div class: 'active-dashboards' do + out = [tag.h3(l(:label_active_dashboard)), + tag.ul do + concat tag.ul "#{l :field_name}: #{dashboard.name}" + concat tag.ul safe_join([l(:field_author), link_to_user(dashboard.author)], ': ') + concat tag.ul "#{l :field_created_on}: #{format_time dashboard.created_at}" + concat tag.ul "#{l :field_updated_on}: #{format_time dashboard.updated_at}" + end] + + if dashboard.description.present? + out << tag.div(textilizable(dashboard, :description, inline_attachments: false), + class: 'dashboard-description') + end + + safe_join out + end + end + + def dashboard_links(title, active_dashboard, dashboards, project) + return '' unless dashboards.any? + + tag.h3(title, class: 'dashboards') + + tag.ul do + dashboards.each do |dashboard| + selected = dashboard.id == if params[:dashboard_id].present? + params[:dashboard_id].to_i + else + active_dashboard.id + end + + css = 'dashboard' + css << ' selected' if selected + link = [dashboard_link(dashboard, project, class: css)] + if dashboard.system_default? + link << if dashboard.project_id.nil? + font_awesome_icon('fas_cube', + title: l(:field_system_default), + class: 'dashboard-system-default global') + else + font_awesome_icon('fas_cube', + title: l(:field_project_system_default), + class: 'dashboard-system-default project') + end + end + concat tag.li safe_join(link) + end + end + end + + def dashboard_link(dashboard, project, options = {}) + if options[:title].blank? && dashboard.public? + author = if dashboard.author_id == User.current.id + l :label_me + else + dashboard.author + end + options[:title] = l(:label_dashboard_author, name: author) + end + + name = options.delete(:name) || dashboard.name + link_to name, dashboard_link_path(project, dashboard), options + end + + def sidebar_action_toggle(enabled, dashboard, project = nil) + return if dashboard.nil? + + if enabled + link_to l(:label_disable_sidebar), + dashboard_link_path(project, dashboard, enable_sidebar: 0), + class: 'icon icon-sidebar' + else + link_to l(:label_enable_sidebar), + dashboard_link_path(project, dashboard, enable_sidebar: 1), + class: 'icon icon-sidebar' + end + end + + def delete_dashboard_link(url, options = {}) + options = { method: :delete, + data: { confirm: l(:text_are_you_sure) }, + class: 'icon icon-del' }.merge(options) + + link_to l(:button_dashboard_delete), url, options + end + + # Returns the select tag used to add or remove a block + def dashboard_block_select_tag(dashboard) + blocks_in_use = dashboard.layout.values.flatten + options = tag.option "<< #{l :label_add_dashboard_block} >>", value: '' + dashboard.content.block_options(blocks_in_use).each do |label, block| + options << tag.option(label, value: block, disabled: block.blank?) + end + select_tag 'block', + options, + id: 'block-select', + class: 'dashboard-block-select', + onchange: "$('#block-form').submit();" + end + + # Renders the blocks + def render_dashboard_blocks(blocks, dashboard, _options = {}) + s = ''.html_safe + + if blocks.present? + blocks.each do |block| + s << render_dashboard_block(block, dashboard).to_s + end + end + s + end + + # Renders a single block + def render_dashboard_block(block, dashboard, overwritten_settings = {}) + block_definition = dashboard.content.find_block block + unless block_definition + Rails.logger.info "Unknown block \"#{block}\" found in #{dashboard.name} (id=#{dashboard.id})" + return + end + + content = render_dashboard_block_content block, block_definition, dashboard, overwritten_settings + return if content.blank? + + if dashboard.editable? + icons = [] + if block_definition[:no_settings].blank? && + (!block_definition.key?(:with_settings_if) || block_definition[:with_settings_if].call(@project)) + icons << link_to_function(l(:label_options), + "$('##{block}-settings').toggle();", + class: 'icon-only icon-settings', + title: l(:label_options)) + end + icons << tag.span('', class: 'icon-only icon-sort-handle sort-handle', title: l(:button_move)) + icons << link_to(l(:button_delete), + _remove_block_dashboard_path(@project, dashboard, block: block), + remote: true, method: 'post', + class: 'icon-only icon-close', title: l(:button_delete)) + + content = tag.div(safe_join(icons), class: 'contextual') + content + end + + tag.div content, class: 'mypage-box', id: "block-#{block}" + end + + def build_dashboard_partial_locals(block, block_definition, settings, dashboard) + partial_locals = { dashboard: dashboard, + settings: settings, + block: block, + block_definition: block_definition, + user: User.current } + + if block_definition[:query_block] + partial_locals[:query_block] = block_definition[:query_block] + partial_locals[:klass] = block_definition[:query_block][:class] + partial_locals[:async] = { required_settings: %i[query_id], + exposed_params: %i[sort], + partial: 'dashboards/blocks/query_list' } + partial_locals[:async][:unique_params] = [Redmine::Utils.random_hex(16)] if params[:refresh].present? + partial_locals[:async] = partial_locals[:async].merge(block_definition[:async]) if block_definition[:async] + elsif block_definition[:async] + partial_locals[:async] = block_definition[:async] + end + + partial_locals + end + + def dashboard_async_required_settings?(settings, async) + return true if async[:required_settings].blank? + return false if settings.blank? + + async[:required_settings].each do |required_setting| + return false if settings.exclude?(required_setting) || settings[required_setting].blank? + end + + true + end + + def dashboard_query_list_block_title(query, query_block, project) + title = [] + title << query.project if project.nil? && query.project + title << query_block[:label] + + title << if query_block[:with_project] + link_to(query.name, send(query_block[:link_helper], project, query.as_params)) + else + link_to(query.name, send(query_block[:link_helper], query.as_params)) + end + + safe_join title, Additionals::LIST_SEPARATOR + end + + def dashboard_query_list_block_alerts(dashboard, query, block_definition) + return if dashboard.visibility == Dashboard::VISIBILITY_PRIVATE + + title = if query.visibility == Query::VISIBILITY_PRIVATE + l(:alert_only_visible_by_yourself) + elsif block_definition.key?(:admin_only) && block_definition[:admin_only] + l(:alert_only_visible_by_admins) + end + + return if title.nil? + + font_awesome_icon('fas_info-circle', + title: title, + class: 'dashboard-block-alert') + end + + def render_legacy_left_block(_block, _block_definition, _settings, _dashboard) + if @project + call_hook :view_projects_show_left, project: @project + else + call_hook :view_welcome_index_left + end + end + + def render_legacy_right_block(_block, _block_definition, _settings, _dashboard) + if @project + call_hook :view_projects_show_right, project: @project + else + call_hook :view_welcome_index_right + end + end + + # copied from my_helper + def render_documents_block(block, _block_definition, settings, dashboard) + max_entries = settings[:max_entries] || DashboardContent::DEFAULT_MAX_ENTRIES + + scope = Document.visible + scope = scope.where(project: dashboard.project) if dashboard.project + + documents = scope.order(created_on: :desc) + .limit(max_entries) + .to_a + + render partial: 'dashboards/blocks/documents', locals: { block: block, + max_entries: max_entries, + documents: documents } + end + + def render_news_block(block, _block_definition, settings, dashboard) + max_entries = settings[:max_entries] || DashboardContent::DEFAULT_MAX_ENTRIES + + news = if dashboard.content_project.nil? + News.latest User.current + else + dashboard.content_project + .news + .limit(max_entries) + .includes(:author, :project) + .reorder(created_on: :desc) + .to_a + end + + render partial: 'dashboards/blocks/news', locals: { block: block, + max_entries: max_entries, + news: news } + end + + def render_my_spent_time_block(block, block_definition, settings, dashboard) + days = settings[:days].to_i + days = 7 if days < 1 || days > 365 + + scope = TimeEntry.where user_id: User.current.id + scope = scope.where(project_id: dashboard.content_project.id) unless dashboard.content_project.nil? + + entries_today = scope.where(spent_on: User.current.today) + entries_days = scope.where(spent_on: User.current.today - (days - 1)..User.current.today) + + render partial: 'dashboards/blocks/my_spent_time', + locals: { block: block, + block_definition: block_definition, + entries_today: entries_today, + entries_days: entries_days, + days: days } + end + + def activity_dashboard_data(settings, dashboard) + max_entries = (settings[:max_entries] || DashboardContent::DEFAULT_MAX_ENTRIES).to_i + user = User.current + options = {} + options[:author] = user if Additionals.true? settings[:me_only] + options[:project] = dashboard.content_project if dashboard.content_project.present? + + Redmine::Activity::Fetcher.new(user, options) + .events(nil, nil, limit: max_entries) + .group_by { |event| user.time_to_date(event.event_datetime) } + end + + def dashboard_feed_catcher(url, max_entries) + feed = { items: [], valid: false } + return feed if url.blank? + + cnt = 0 + max_entries = max_entries.present? ? max_entries.to_i : 10 + + begin + URI.open(url) do |rss_feed| + rss = RSS::Parser.parse(rss_feed) + rss.items.each do |item| + cnt += 1 + feed[:items] << { title: item.title.try(:content)&.presence || item.title, + link: item.link.try(:href)&.presence || item.link } + break if cnt >= max_entries + end + end + rescue StandardError => e + Rails.logger.info "dashboard_feed_catcher error for #{url}: #{e}" + return feed + end + + feed[:valid] = true + + feed + end + + def dashboard_feed_title(title, block_definition) + title.presence || block_definition[:label] + end + + def options_for_query_select(klass, project) + # sidebar_queries cannot be use because descendants classes are included + # this changes on class loading + # queries = klass.visible.global_or_on_project(@project).sorted.to_a + queries = klass.visible + .global_or_on_project(project) + .where(type: klass.to_s) + .sorted.to_a + + tag.option + options_from_collection_for_select(queries, :id, :name) + end + + private + + # Renders a single block content + def render_dashboard_block_content(block, block_definition, dashboard, overwritten_settings = {}) + settings = dashboard.layout_settings block + settings = settings.merge(overwritten_settings) if overwritten_settings.present? + + partial = block_definition[:partial] + partial_locals = build_dashboard_partial_locals block, block_definition, settings, dashboard + + if block_definition[:query_block] || block_definition[:async] + render partial: 'dashboards/blocks/async', locals: partial_locals + elsif partial + begin + render partial: partial, locals: partial_locals + rescue ActionView::MissingTemplate + Rails.logger.warn("Partial \"#{partial}\" missing for block \"#{block}\" found in #{dashboard.name} (id=#{dashboard.id})") + nil + end + else + send "render_#{block_definition[:name]}_block", + block, + block_definition, + settings, + dashboard + end + end + + def resently_used_dashboard_save(dashboard, project = nil) + user = User.current + dashboard_type = dashboard.dashboard_type + recently_id = user.pref.recently_used_dashboard dashboard_type, project + return if recently_id == dashboard.id || user.anonymous? + + if dashboard_type == DashboardContentProject::TYPE_NAME + user.pref.recently_used_dashboards[dashboard_type] = {} if user.pref.recently_used_dashboards[dashboard_type].nil? + user.pref.recently_used_dashboards[dashboard_type][project.id] = dashboard.id + else + user.pref.recently_used_dashboards[dashboard_type] = dashboard.id + end + + user.pref.save + end +end diff --git a/plugins/additionals/app/jobs/additionals_job.rb b/plugins/additionals/app/jobs/additionals_job.rb new file mode 100644 index 0000000..613249a --- /dev/null +++ b/plugins/additionals/app/jobs/additionals_job.rb @@ -0,0 +1,7 @@ +class AdditionalsJob < ActiveJob::Base + # Automatically retry jobs that encountered a deadlock + # retry_on ActiveRecord::Deadlocked + + # Most jobs are safe to ignore if the underlying records are no longer available + # discard_on ActiveJob::DeserializationError +end diff --git a/plugins/additionals/app/jobs/additionals_remove_unused_tag_job.rb b/plugins/additionals/app/jobs/additionals_remove_unused_tag_job.rb new file mode 100644 index 0000000..7453c76 --- /dev/null +++ b/plugins/additionals/app/jobs/additionals_remove_unused_tag_job.rb @@ -0,0 +1,5 @@ +class AdditionalsRemoveUnusedTagJob < AdditionalsJob + def perform + AdditionalsTag.remove_unused_tags + end +end diff --git a/plugins/additionals/app/models/additionals_chart.rb b/plugins/additionals/app/models/additionals_chart.rb new file mode 100644 index 0000000..ed1fdc5 --- /dev/null +++ b/plugins/additionals/app/models/additionals_chart.rb @@ -0,0 +1,64 @@ +class AdditionalsChart < ActiveRecord::Base + include Redmine::I18n + + CHART_DEFAULT_HEIGHT = 350 + CHART_DEFAULT_WIDTH = 400 + + class << self + def color_schema + Redmine::Plugin.installed?('redmine_reporting') ? RedmineReporting.setting(:chart_color_schema) : 'tableau.Classic20' + end + + def data + raise 'overwrite it!' + end + + # build return value + def build_chart_data(datasets, options = {}) + cached_labels = labels + data = { datasets: datasets.to_json, + labels: cached_labels.keys, + label_ids: cached_labels.values } + + required_labels = options.key?(:required_labels) ? options.delete(:required_labels) : 2 + + data[:valid] = cached_labels.any? && cached_labels.count >= required_labels unless options.key?(:valid) + data[:width] = self::CHART_DEFAULT_WIDTH unless options.key?(:width) + data[:height] = self::CHART_DEFAULT_HEIGHT unless options.key?(:height) + data[:value_link_method] = '_project_issues_path' unless options.key?(:value_link_method) + data[:color_schema] = color_schema + + data.merge(options) + end + + private + + def build_values_without_gaps(data, gap_value = 0) + values = [] + labels.each do |label, _label_id| + values << if data.key?(label) + data[label] + else + gap_value + end + end + + values + end + + def init_labels + @labels = {} + end + + def labels + # NOTE: do not sort it, because color changes if user switch language + @labels.to_h + end + + def add_label(label, id) + return if @labels.key? label + + @labels[label] = id + end + end +end diff --git a/plugins/additionals/app/models/additionals_font_awesome.rb b/plugins/additionals/app/models/additionals_font_awesome.rb index 2318b78..ead1529 100755 --- a/plugins/additionals/app/models/additionals_font_awesome.rb +++ b/plugins/additionals/app/models/additionals_font_awesome.rb @@ -1,12 +1,12 @@ class AdditionalsFontAwesome include Redmine::I18n + FORMAT_REGEXP = /\Afa[rsb]_[a-zA-Z0-9]+[a-zA-Z0-9\-]*\z/.freeze + SEARCH_LIMIT = 50 + class << self def load_icons(type) - data = YAML.safe_load(ERB.new(IO.read(Rails.root.join('plugins', - 'additionals', - 'config', - 'fontawesome_icons.yml'))).result) || {} + data = YAML.safe_load(ERB.new(IO.read(Rails.root.join('plugins/additionals/config/fontawesome_icons.yml'))).result) || {} icons = {} data.each do |key, values| icons[key] = { unicode: values['unicode'], label: values['label'] } if values['styles'].include?(convert_type2style(type)) @@ -62,12 +62,6 @@ class AdditionalsFontAwesome FONTAWESOME_ICONS[type].collect { |fa_symbol, values| [values[:label], key2value(fa_symbol, type[-1])] } end - def json_for_select - [{ text: l(:label_fontawesome_regular), children: json_values(:far) }, - { text: l(:label_fontawesome_solid), children: json_values(:fas) }, - { text: l(:label_fontawesome_brands), children: json_values(:fab) }].to_json - end - # show only one value as current selected # (all other options are retrieved by select2 def active_option_for_select(selected) @@ -77,12 +71,6 @@ class AdditionalsFontAwesome [[info[:label], selected]] end - def options_for_select - [[l(:label_fontawesome_regular), select_values(:far)], - [l(:label_fontawesome_solid), select_values(:fas)], - [l(:label_fontawesome_brands), select_values(:fab)]] - end - def value_info(value, options = {}) return {} if value.blank? @@ -104,8 +92,60 @@ class AdditionalsFontAwesome info end + def search_for_select(search, selected = nil) + # could be more then one + selected_store = selected.to_s.split(',') + icons = search_in_type(:far, search, selected_store) + cnt = icons.count + return icons if cnt >= SEARCH_LIMIT + + icons += search_in_type(:fas, search, selected_store, cnt) + cnt = icons.count + return icons if cnt >= SEARCH_LIMIT + + icons + search_in_type(:fab, search, selected_store, cnt) + end + + def convert2mermaid(icon) + return if icon.blank? + + parts = icon.split('_') + return unless parts.count == 2 + + "#{parts.first}:fa-#{parts.last}" + end + private + def search_in_type(type, search, selected_store, cnt = 0) + icons = [] + + search_length = search.to_s.length + first_letter_search = if search_length == 1 + search[0].downcase + elsif search_length.zero? && selected_store.any? + selected = selected_store.first + fa = selected.split('_') + search = fa[1][0] if fa.count > 1 + search + end + + FONTAWESOME_ICONS[type].each do |fa_symbol, values| + break if SEARCH_LIMIT == cnt + + id = key2value(fa_symbol, type[-1]) + next if selected_store.exclude?(id) && + search.present? && + (first_letter_search.present? && !values[:label].downcase.start_with?(first_letter_search) || + first_letter_search.blank? && values[:label] !~ /#{search}/i) + + icons << { id: id, text: values[:label] } + cnt += 1 + end + + icons + end + def load_details(type, name) return {} unless FONTAWESOME_ICONS.key?(type) diff --git a/plugins/additionals/app/models/additionals_import.rb b/plugins/additionals/app/models/additionals_import.rb index f5f081b..0b4c406 100755 --- a/plugins/additionals/app/models/additionals_import.rb +++ b/plugins/additionals/app/models/additionals_import.rb @@ -25,8 +25,6 @@ class AdditionalsImport < Import value = case v.custom_field.field_format when 'date' row_date(row, "cf_#{v.custom_field.id}") - when 'list' - row_value(row, "cf_#{v.custom_field.id}").try(:split, ',') else row_value(row, "cf_#{v.custom_field.id}") end @@ -34,7 +32,7 @@ class AdditionalsImport < Import h[v.custom_field.id.to_s] = if value.is_a?(Array) - value.map { |val| v.custom_field.value_from_keyword(val.strip, object) }.compact.flatten + value.map { |val| v.custom_field.value_from_keyword(val.strip, object) }.flatten!&.compact else v.custom_field.value_from_keyword(value, object) end diff --git a/plugins/additionals/app/models/additionals_journal.rb b/plugins/additionals/app/models/additionals_journal.rb new file mode 100644 index 0000000..e6cc255 --- /dev/null +++ b/plugins/additionals/app/models/additionals_journal.rb @@ -0,0 +1,71 @@ +class AdditionalsJournal + class << self + def save_journal_history(journal, prop_key, ids_old, ids) + ids_all = (ids_old + ids).uniq + + ids_all.each do |id| + next if ids_old.include?(id) && ids.include?(id) + + if ids.include?(id) + value = id + old_value = nil + else + old_value = id + value = nil + end + + journal.details << JournalDetail.new(property: 'attr', + prop_key: prop_key, + old_value: old_value, + value: value) + journal.save + end + + true + end + + def validate_relation(entries, entry_id) + old_entries = entries.select { |entry| entry.id.present? } + new_entries = entries.select { |entry| entry.id.blank? } + return true if new_entries.blank? + + new_entries.map! { |entry| entry.send(entry_id) } + return false if new_entries.count != new_entries.uniq.count + + old_entries.map! { |entry| entry.send(entry_id) } + return false unless (old_entries & new_entries).count.zero? + + true + end + + # Preloads visible last notes for a collection of entity + # this is a copy of Issue.load_visible_last_notes, but usable for all entities + # @see https://www.redmine.org/projects/redmine/repository/entry/trunk/app/models/issue.rb#L1214 + def load_visible_last_notes(entries, entity, user = User.current) + return unless entries.any? + + ids = entries.map(&:id) + + journal_class = (entity == Issue ? Journal : "#{entity}Journal").constantize + journal_ids = journal_class.joins(entity.name.underscore.to_sym => :project) + .where(journalized_type: entity.to_s, journalized_id: ids) + .where(journal_class.visible_notes_condition(user, skip_pre_condition: true)) + .where.not(notes: '') + .group(:journalized_id) + .maximum(:id) + .values + + journals = Journal.where(id: journal_ids).to_a + + entries.each do |entry| + journal = journals.detect { |j| j.journalized_id == entry.id } + entry.instance_variable_set('@last_notes', journal.try(:notes) || '') + end + end + + def set_relation_detail(entity, detail, value_key) + value = detail.send value_key + detail[value_key] = (entity.find_by(id: value) || value) if value.present? + end + end +end diff --git a/plugins/additionals/app/models/additionals_macro.rb b/plugins/additionals/app/models/additionals_macro.rb index 990dc26..b571892 100755 --- a/plugins/additionals/app/models/additionals_macro.rb +++ b/plugins/additionals/app/models/additionals_macro.rb @@ -62,7 +62,7 @@ class AdditionalsMacro permission: :view_contacts }, { list: %i[db db_query db_tag db_tag_count], permission: :view_db_entries }, - { list: %i[child_pages calendar last_updated_at last_updated_by lastupdated_at lastupdated_by + { list: %i[child_pages last_updated_at last_updated_by lastupdated_at lastupdated_by new_page recently_updated recent comments comment_form tags taggedpages tagcloud show_count count vote show_vote terms_accept terms_reject], permission: :view_wiki_pages, diff --git a/plugins/additionals/app/models/additionals_query.rb b/plugins/additionals/app/models/additionals_query.rb index fea2f0f..6b389ff 100755 --- a/plugins/additionals/app/models/additionals_query.rb +++ b/plugins/additionals/app/models/additionals_query.rb @@ -1,150 +1,215 @@ module AdditionalsQuery - def self.included(base) - base.send :include, InstanceMethods + def column_with_prefix?(prefix) + columns.detect { |c| c.name.to_s.start_with?("#{prefix}.") }.present? end - module InstanceMethods - def initialize_ids_filter(options = {}) - if options[:label] - add_available_filter 'ids', type: :integer, label: options[:label] + def available_column_names(options = {}) + names = available_columns.dup + names.flatten! + names.select! { |col| col.sortable.present? } if options[:only_sortable] + names.map(&:name) + end + + def sql_for_enabled_module(table_field, module_names) + module_names = Array(module_names) + + sql = [] + module_names.each do |module_name| + sql << "EXISTS(SELECT 1 FROM #{EnabledModule.table_name} WHERE #{EnabledModule.table_name}.project_id=#{table_field}" \ + " AND #{EnabledModule.table_name}.name='#{module_name}')" + end + + sql.join(' AND ') + end + + def initialize_ids_filter(options = {}) + if options[:label] + add_available_filter 'ids', type: :integer, label: options[:label] + else + add_available_filter 'ids', type: :integer, name: '#' + end + end + + def sql_for_ids_field(_field, operator, value) + if operator == '=' + # accepts a comma separated list of ids + ids = value.first.to_s.scan(/\d+/).map(&:to_i) + if ids.present? + "#{queried_table_name}.id IN (#{ids.join ','})" else - add_available_filter 'ids', type: :integer, name: '#' + '1=0' end + else + sql_for_field 'id', operator, value, queried_table_name, 'id' end + end - def sql_for_ids_field(_field, operator, value) - if operator == '=' - # accepts a comma separated list of ids - ids = value.first.to_s.scan(/\d+/).map(&:to_i) - if ids.present? - "#{queried_table_name}.id IN (#{ids.join(',')})" - else - '1=0' - end - else - sql_for_field('id', operator, value, queried_table_name, 'id') - end - end + def sql_for_project_status_field(field, operator, value) + sql_for_field field, operator, value, Project.table_name, 'status' + end - def initialize_project_filter(options = {}) - if project.nil? - add_available_filter('project_id', order: options[:position], - type: :list, - values: -> { project_values }) - end - return if project.nil? || project.leaf? || subproject_values.empty? + def initialize_project_status_filter + return if project&.leaf? - add_available_filter('subproject_id', order: options[:position], - type: :list_subprojects, - values: -> { subproject_values }) - end + add_available_filter('project.status', + type: :list, + name: l(:label_attribute_of_project, name: l(:field_status)), + values: -> { project_statuses_values }) + end - def initialize_created_filter(options = {}) - add_available_filter 'created_on', order: options[:position], - type: :date_past, - label: options[:label].presence - end - - def initialize_updated_filter(options = {}) - add_available_filter 'updated_on', order: options[:position], - type: :date_past, - label: options[:label].presence - end - - def initialize_tags_filter(options = {}) - values = if project - queried_class.available_tags(project: project.id) - else - queried_class.available_tags - end - return if values.blank? - - add_available_filter 'tags', order: options[:position], - type: :list, - values: values.collect { |t| [t.name, t.name] } - end - - def initialize_author_filter(options = {}) - return if author_values.empty? - - add_available_filter('author_id', order: options[:position], - type: :list_optional, - values: options[:no_lambda].nil? ? author_values : -> { author_values }) - end - - def initialize_assignee_filter(options = {}) - return if author_values.empty? - - add_available_filter('assigned_to_id', order: options[:position], - type: :list_optional, - values: options[:no_lambda] ? author_values : -> { author_values }) - end - - def initialize_watcher_filter(options = {}) - return if watcher_values.empty? || !User.current.logged? - - add_available_filter('watcher_id', order: options[:position], + def initialize_project_filter(options = {}) + if project.nil? || options[:always] + add_available_filter('project_id', order: options[:position], type: :list, - values: options[:no_lambda] ? watcher_values : -> { watcher_values }) + values: -> { project_values }) end + return if project.nil? || project.leaf? || subproject_values.empty? - def watcher_values - watcher_values = [["<< #{l(:label_me)} >>", 'me']] - watcher_values += users.collect { |s| [s.name, s.id.to_s] } if User.current.allowed_to?(:manage_public_queries, project, global: true) - watcher_values + add_available_filter('subproject_id', order: options[:position], + type: :list_subprojects, + values: -> { subproject_values }) + end + + def initialize_created_filter(options = {}) + add_available_filter 'created_on', order: options[:position], + type: :date_past, + label: options[:label].presence + end + + def initialize_updated_filter(options = {}) + add_available_filter 'updated_on', order: options[:position], + type: :date_past, + label: options[:label].presence + end + + def initialize_tags_filter(options = {}) + values = if project + queried_class.available_tags(project: project.id) + else + queried_class.available_tags + end + return if values.blank? + + add_available_filter 'tags', order: options[:position], + type: :list, + values: values.collect { |t| [t.name, t.name] } + end + + def initialize_approved_filter + add_available_filter 'approved', + type: :list, + values: [[l(:label_hrm_approved), '1'], + [l(:label_hrm_not_approved), '0'], + [l(:label_hrm_to_approval), '2'], + [l(:label_hrm_without_approval), '3']], + label: :field_approved + end + + def initialize_author_filter(options = {}) + return if author_values.empty? + + add_available_filter('author_id', order: options[:position], + type: :list_optional, + values: options[:no_lambda].nil? ? author_values : -> { author_values }) + end + + def initialize_assignee_filter(options = {}) + return if author_values.empty? + + add_available_filter('assigned_to_id', order: options[:position], + type: :list_optional, + values: options[:no_lambda] ? assigned_to_all_values : -> { assigned_to_all_values }) + end + + def initialize_watcher_filter(options = {}) + return if watcher_values.empty? || !User.current.logged? + + add_available_filter('watcher_id', order: options[:position], + type: :list, + values: options[:no_lambda] ? watcher_values : -> { watcher_values }) + end + + # issue independend values. Use assigned_to_values from Redmine, if you want it only for issues + def assigned_to_all_values + assigned_to_values = [] + assigned_to_values << ["<< #{l :label_me} >>", 'me'] if User.current.logged? + assigned_to_values += principals.sort_by(&:status).collect { |s| [s.name, s.id.to_s, l("status_#{User::LABEL_BY_STATUS[s.status]}")] } + + assigned_to_values + end + + def watcher_values + watcher_values = [["<< #{l :label_me} >>", 'me']] + watcher_values += users.collect { |s| [s.name, s.id.to_s] } if User.current.allowed_to?(:manage_public_queries, project, global: true) + watcher_values + end + + def sql_for_watcher_id_field(field, operator, value) + watchable_type = queried_class == User ? 'Principal' : queried_class.to_s + + db_table = Watcher.table_name + "#{queried_table_name}.id #{operator == '=' ? 'IN' : 'NOT IN'}" \ + " (SELECT #{db_table}.watchable_id FROM #{db_table} WHERE #{db_table}.watchable_type='#{watchable_type}' AND" \ + " #{sql_for_field field, '=', value, db_table, 'user_id'})" + end + + def sql_for_tags_field(field, _operator, value) + AdditionalsTag.sql_for_tags_field(queried_class, operator_for(field), value) + end + + def sql_for_is_private_field(_field, operator, value) + if bool_operator(operator, value) + return '' if value.count > 1 + + "#{queried_table_name}.is_private = #{self.class.connection.quoted_true}" + else + return '1=0' if value.count > 1 + + "#{queried_table_name}.is_private = #{self.class.connection.quoted_false}" end + end - def sql_for_watcher_id_field(field, operator, value) - watchable_type = queried_class == User ? 'Principal' : queried_class.to_s + # use for list fields with to values 1 (true) and 0 (false) + def bool_operator(operator, values) + operator == '=' && values.first == '1' || operator != '=' && values.first != '1' + end - db_table = Watcher.table_name - "#{queried_table_name}.id #{operator == '=' ? 'IN' : 'NOT IN'} - (SELECT #{db_table}.watchable_id FROM #{db_table} WHERE #{db_table}.watchable_type='#{watchable_type}' AND " + - sql_for_field(field, '=', value, db_table, 'user_id') + ')' - end + # use for list + def bool_values + [[l(:general_text_yes), '1'], [l(:general_text_no), '0']] + end - def sql_for_tags_field(field, _operator, value) - AdditionalsTag.sql_for_tags_field(queried_class, operator_for(field), value) - end + def query_count + objects_scope.count + rescue ::ActiveRecord::StatementInvalid => e + raise queried_class::StatementInvalid, e.message if defined? queried_class::StatementInvalid - def sql_for_is_private_field(_field, operator, value) - if bool_operator(operator, value) - return '1=1' if value.count > 1 + raise ::Query::StatementInvalid, e.message + end - "#{queried_table_name}.is_private = #{self.class.connection.quoted_true}" - else - return '1=0' if value.count > 1 + def results_scope(options = {}) + order_option = [group_by_sort_order, (options[:order] || sort_clause)].flatten!.to_a.reject(&:blank?) - "#{queried_table_name}.is_private = #{self.class.connection.quoted_false}" + objects_scope(options) + .order(order_option) + .joins(joins_for_order_statement(order_option.join(','))) + .limit(options[:limit]) + .offset(options[:offset]) + rescue ::ActiveRecord::StatementInvalid => e + raise queried_class::StatementInvalid, e.message if defined? queried_class::StatementInvalid + + raise ::Query::StatementInvalid, e.message + end + + def grouped_name_for(group_name, replace_fields = {}) + return unless group_name + + if grouped? && group_by_column.present? + replace_fields.each do |field, new_name| + return new_name.presence || group_name if group_by_column.name == field end end - # use for list fields with to values 1 (true) and 0 (false) - def bool_operator(operator, values) - operator == '=' && values.first == '1' || operator != '=' && values.first != '1' - end - - # use for list - def bool_values - [[l(:general_text_yes), '1'], [l(:general_text_no), '0']] - end - - def query_count - objects_scope.count - rescue ::ActiveRecord::StatementInvalid => e - raise StatementInvalid, e.message - end - - def results_scope(options = {}) - order_option = [group_by_sort_order, (options[:order] || sort_clause)].flatten.reject(&:blank?) - - objects_scope(options) - .order(order_option) - .joins(joins_for_order_statement(order_option.join(','))) - .limit(options[:limit]) - .offset(options[:offset]) - rescue ::ActiveRecord::StatementInvalid => e - raise StatementInvalid, e.message - end + group_name end end diff --git a/plugins/additionals/app/models/additionals_tag.rb b/plugins/additionals/app/models/additionals_tag.rb index f30e559..db9c893 100755 --- a/plugins/additionals/app/models/additionals_tag.rb +++ b/plugins/additionals/app/models/additionals_tag.rb @@ -3,68 +3,74 @@ class AdditionalsTag TAGGING_TABLE_NAME = RedmineCrm::Tagging.table_name if defined? RedmineCrm PROJECT_TABLE_NAME = Project.table_name - def self.get_available_tags(klass, options = {}) - scope = RedmineCrm::Tag.where({}) - scope = scope.where("#{PROJECT_TABLE_NAME}.id = ?", options[:project]) if options[:project] - if options[:permission] - scope = scope.where(tag_access(options[:permission])) - elsif options[:visible_condition] - scope = scope.where(klass.visible_condition(User.current)) - end - scope = scope.where("LOWER(#{TAG_TABLE_NAME}.name) LIKE ?", "%#{options[:name_like].downcase}%") if options[:name_like] - scope = scope.where("#{TAG_TABLE_NAME}.name=?", options[:name]) if options[:name] - scope = scope.where("#{TAGGING_TABLE_NAME}.taggable_id!=?", options[:exclude_id]) if options[:exclude_id] - scope = scope.where(options[:where_field] => options[:where_value]) if options[:where_field].present? && options[:where_value] - - scope = scope.select("#{TAG_TABLE_NAME}.*, COUNT(DISTINCT #{TAGGING_TABLE_NAME}.taggable_id) AS count") - scope = scope.joins(tag_joins(klass, options)) - scope = scope.group("#{TAG_TABLE_NAME}.id, #{TAG_TABLE_NAME}.name").having('COUNT(*) > 0') - scope = scope.order("#{TAG_TABLE_NAME}.name") - scope - end - - def self.tag_joins(klass, options = {}) - table_name = klass.table_name - - joins = ["JOIN #{TAGGING_TABLE_NAME} ON #{TAGGING_TABLE_NAME}.tag_id = #{TAG_TABLE_NAME}.id"] - joins << "JOIN #{table_name} " \ - "ON #{table_name}.id = #{TAGGING_TABLE_NAME}.taggable_id AND #{TAGGING_TABLE_NAME}.taggable_type = '#{klass}'" - - if options[:project_join] - joins << options[:project_join] - elsif options[:project] || !options[:without_projects] - joins << "JOIN #{PROJECT_TABLE_NAME} ON #{table_name}.project_id = #{PROJECT_TABLE_NAME}.id" + class << self + def all_type_tags(klass, options = {}) + RedmineCrm::Tag.where({}) + .joins(tag_joins(klass, options)) + .distinct + .order("#{TAG_TABLE_NAME}.name") end - joins - end + def get_available_tags(klass, options = {}) + scope = RedmineCrm::Tag.where({}) + scope = scope.where("#{PROJECT_TABLE_NAME}.id = ?", options[:project]) if options[:project] + if options[:permission] + scope = scope.where(tag_access(options[:permission])) + elsif options[:visible_condition] + scope = scope.where(klass.visible_condition(User.current)) + end + scope = scope.where("LOWER(#{TAG_TABLE_NAME}.name) LIKE ?", "%#{options[:name_like].downcase}%") if options[:name_like] + scope = scope.where("#{TAG_TABLE_NAME}.name=?", options[:name]) if options[:name] + scope = scope.where("#{TAGGING_TABLE_NAME}.taggable_id!=?", options[:exclude_id]) if options[:exclude_id] + scope = scope.where(options[:where_field] => options[:where_value]) if options[:where_field].present? && options[:where_value] - def self.tag_access(permission) - projects_allowed = if permission.nil? - Project.visible.pluck(:id) - else - Project.where(Project.allowed_to_condition(User.current, permission)).pluck(:id) - end - - if projects_allowed.present? - "#{PROJECT_TABLE_NAME}.id IN (#{projects_allowed.join(',')})" unless projects_allowed.empty? - else - '1=0' + scope.select("#{TAG_TABLE_NAME}.*, COUNT(DISTINCT #{TAGGING_TABLE_NAME}.taggable_id) AS count") + .joins(tag_joins(klass, options)) + .group("#{TAG_TABLE_NAME}.id, #{TAG_TABLE_NAME}.name").having('COUNT(*) > 0') + .order("#{TAG_TABLE_NAME}.name") end - end - def self.remove_unused_tags - unused = RedmineCrm::Tag.find_by_sql(<<-SQL) - SELECT * FROM tags WHERE id NOT IN ( - SELECT DISTINCT tag_id FROM taggings - ) - SQL - unused.each(&:destroy) - end + def remove_unused_tags + RedmineCrm::Tag.where.not(id: RedmineCrm::Tagging.select(:tag_id).distinct) + .each(&:destroy) + end - def self.sql_for_tags_field(klass, operator, value) - compare = operator.eql?('=') ? 'IN' : 'NOT IN' - ids_list = klass.tagged_with(value).collect(&:id).push(0).join(',') - "( #{klass.table_name}.id #{compare} (#{ids_list}) ) " + def sql_for_tags_field(klass, operator, value) + compare = operator.eql?('=') ? 'IN' : 'NOT IN' + ids_list = klass.tagged_with(value).collect(&:id).push(0).join(',') + "( #{klass.table_name}.id #{compare} (#{ids_list}) ) " + end + + private + + def tag_access(permission) + projects_allowed = if permission.nil? + Project.visible.ids + else + Project.where(Project.allowed_to_condition(User.current, permission)).ids + end + + if projects_allowed.present? + "#{PROJECT_TABLE_NAME}.id IN (#{projects_allowed.join ','})" unless projects_allowed.empty? + else + '1=0' + end + end + + def tag_joins(klass, options = {}) + table_name = klass.table_name + + joins = ["JOIN #{TAGGING_TABLE_NAME} ON #{TAGGING_TABLE_NAME}.tag_id = #{TAG_TABLE_NAME}.id"] + joins << "JOIN #{table_name} " \ + "ON #{table_name}.id = #{TAGGING_TABLE_NAME}.taggable_id AND #{TAGGING_TABLE_NAME}.taggable_type = '#{klass}'" + + if options[:project_join] + joins << options[:project_join] + elsif options[:project] || !options[:without_projects] + joins << "JOIN #{PROJECT_TABLE_NAME} ON #{table_name}.project_id = #{PROJECT_TABLE_NAME}.id" + end + + joins + end end end diff --git a/plugins/additionals/app/models/dashboard.rb b/plugins/additionals/app/models/dashboard.rb new file mode 100644 index 0000000..ab4f9d7 --- /dev/null +++ b/plugins/additionals/app/models/dashboard.rb @@ -0,0 +1,423 @@ +class Dashboard < ActiveRecord::Base + include Redmine::I18n + include Redmine::SafeAttributes + include Additionals::EntityMethods + + class SystemDefaultChangeException < StandardError; end + class ProjectSystemDefaultChangeException < StandardError; end + + belongs_to :project + belongs_to :author, class_name: 'User' + + # current active project (belongs_to :project can be nil, because this is system default) + attr_accessor :content_project + + serialize :options + + has_many :dashboard_roles, dependent: :destroy + has_many :roles, through: :dashboard_roles + + VISIBILITY_PRIVATE = 0 + VISIBILITY_ROLES = 1 + VISIBILITY_PUBLIC = 2 + + scope :by_project, (->(project_id) { where(project_id: project_id) if project_id.present? }) + scope :sorted, (-> { order("#{Dashboard.table_name}.name") }) + scope :welcome_only, (-> { where(dashboard_type: DashboardContentWelcome::TYPE_NAME) }) + scope :project_only, (-> { where(dashboard_type: DashboardContentProject::TYPE_NAME) }) + + safe_attributes 'name', 'description', 'enable_sidebar', + 'always_expose', 'project_id', 'author_id', + if: (lambda do |dashboard, user| + dashboard.new_record? || + user.allowed_to?(:save_dashboards, dashboard.project, global: true) + end) + + safe_attributes 'dashboard_type', + if: (lambda do |dashboard, _user| + dashboard.new_record? + end) + + safe_attributes 'visibility', 'role_ids', + if: (lambda do |dashboard, user| + user.allowed_to?(:share_dashboards, dashboard.project, global: true) || + user.allowed_to?(:set_system_dashboards, dashboard.project, global: true) + end) + + safe_attributes 'system_default', + if: (lambda do |dashboard, user| + user.allowed_to?(:set_system_dashboards, dashboard.project, global: true) + end) + + before_save :dashboard_type_check, :visibility_check, :set_options_hash, :clear_unused_block_settings + + before_destroy :check_destroy_system_default + after_save :update_system_defaults + after_save :remove_unused_role_relations + + validates :name, :dashboard_type, :author, :visibility, presence: true + validates :visibility, inclusion: { in: [VISIBILITY_PUBLIC, VISIBILITY_ROLES, VISIBILITY_PRIVATE] } + validate :validate_roles + validate :validate_visibility + validate :validate_name + validate :validate_system_default + validate :validate_project_system_default + + class << self + def system_default(dashboard_type) + select(:id).find_by(dashboard_type: dashboard_type, system_default: true) + .try(:id) + end + + def default(dashboard_type, project = nil, user = User.current) + recently_id = User.current.pref.recently_used_dashboard dashboard_type, project + + scope = where(dashboard_type: dashboard_type) + scope = scope.where(project_id: project.id).or(scope.where(project_id: nil)) if project.present? + + dashboard = scope.visible.find_by(id: recently_id) if recently_id.present? + + if dashboard.blank? + scope = scope.where(system_default: true).or(scope.where(author_id: user.id)) + dashboard = scope.order(system_default: :desc, project_id: :desc, id: :asc).first + + if recently_id.present? + Rails.logger.debug 'default cleanup required' + # Remove invalid recently_id + if project.present? + User.current.pref.recently_used_dashboards[dashboard_type].delete(project.id) + else + User.current.pref.recently_used_dashboards[dashboard_type] = nil + end + end + end + + dashboard + end + + def fields_for_order_statement(table = nil) + table ||= table_name + ["#{table}.name"] + end + + def visible(user = User.current, options = {}) + scope = left_outer_joins :project + scope = scope.where(projects: { id: nil }).or(scope.where(Project.allowed_to_condition(user, :view_project, options))) + + if user.admin? + scope.where.not(visibility: VISIBILITY_PRIVATE).or(scope.where(author_id: user.id)) + elsif user.memberships.any? + scope.where("#{table_name}.visibility = ?" \ + " OR (#{table_name}.visibility = ? AND #{table_name}.id IN (" \ + "SELECT DISTINCT d.id FROM #{table_name} d" \ + " INNER JOIN #{table_name_prefix}dashboard_roles#{table_name_suffix} dr ON dr.dashboard_id = d.id" \ + " INNER JOIN #{MemberRole.table_name} mr ON mr.role_id = dr.role_id" \ + " INNER JOIN #{Member.table_name} m ON m.id = mr.member_id AND m.user_id = ?" \ + " INNER JOIN #{Project.table_name} p ON p.id = m.project_id AND p.status <> ?" \ + ' WHERE d.project_id IS NULL OR d.project_id = m.project_id))' \ + " OR #{table_name}.author_id = ?", + VISIBILITY_PUBLIC, + VISIBILITY_ROLES, + user.id, + Project::STATUS_ARCHIVED, + user.id) + elsif user.logged? + scope.where(visibility: VISIBILITY_PUBLIC).or(scope.where(author_id: user.id)) + else + scope.where visibility: VISIBILITY_PUBLIC + end + end + end + + def initialize(attributes = nil, *args) + super + set_options_hash + end + + def set_options_hash + self.options ||= {} + end + + def [](attr_name) + if has_attribute? attr_name + super + else + options ? options[attr_name] : nil + end + end + + def []=(attr_name, value) + if has_attribute? attr_name + super + else + h = (self[:options] || {}).dup + h.update(attr_name => value) + self[:options] = h + value + end + end + + # Returns true if the dashboard is visible to +user+ or the current user. + def visible?(user = User.current) + return true if user.admin? + return false unless project.nil? || user.allowed_to?(:view_project, project) + return true if user == author + + case visibility + when VISIBILITY_PUBLIC + true + when VISIBILITY_ROLES + if project + (user.roles_for_project(project) & roles).any? + else + user.memberships.joins(:member_roles).where(member_roles: { role_id: roles.map(&:id) }).any? + end + end + end + + def content + @content ||= "DashboardContent#{dashboard_type[0..-10]}".constantize.new(project: content_project.presence || project) + end + + def available_groups + content.groups + end + + def layout + self[:layout] ||= content.default_layout.deep_dup + end + + def layout=(arg) + self[:layout] = arg + end + + def layout_settings(block = nil) + s = self[:layout_settings] ||= {} + if block + s[block] ||= {} + else + s + end + end + + def layout_settings=(arg) + self[:layout_settings] = arg + end + + def remove_block(block) + block = block.to_s.underscore + layout.each_key do |group| + layout[group].delete block + end + layout + end + + # Adds block to the user page layout + # Returns nil if block is not valid or if it's already + # present in the user page layout + def add_block(block) + block = block.to_s.underscore + return unless content.valid_block? block, layout.values.flatten + + remove_block block + # add it to the first group + # add it to the first group + group = available_groups.first + layout[group] ||= [] + layout[group].unshift block + end + + # Sets the block order for the given group. + # Example: + # preferences.order_blocks('left', ['issueswatched', 'news']) + def order_blocks(group, blocks) + group = group.to_s + return if content.groups.exclude?(group) || blocks.blank? + + blocks = blocks.map(&:underscore) & layout.values.flatten + blocks.each { |block| remove_block(block) } + layout[group] = blocks + end + + def update_block_settings(block, settings) + block = block.to_s + block_settings = layout_settings(block).merge(settings.symbolize_keys) + layout_settings[block] = block_settings + end + + def private?(user = User.current) + author_id == user.id && visibility == VISIBILITY_PRIVATE + end + + def public? + visibility != VISIBILITY_PRIVATE + end + + def editable_by?(usr = User.current, prj = nil) + prj ||= project + usr && (usr.admin? || + (author == usr && usr.allowed_to?(:save_dashboards, prj, global: true))) + end + + def editable?(usr = User.current) + @editable ||= editable_by?(usr) + end + + def destroyable_by?(usr = User.current) + return unless editable_by? usr, project + + return !system_default_was if dashboard_type != DashboardContentProject::TYPE_NAME + + # project dashboards needs special care + project.present? || !system_default_was + end + + def destroyable? + @destroyable ||= destroyable_by?(User.current) + end + + def to_s + name + end + + # Returns a string of css classes that apply to the entry + def css_classes(user = User.current) + s = ['dashboard'] + s << 'created-by-me' if author_id == user.id + s.join(' ') + end + + def allowed_target_projects(user = User.current) + Project.where Project.allowed_to_condition(user, :save_dashboards) + end + + # this is used to get unique cache for blocks + def async_params(block, options, settings = {}) + if block.blank? + msg = 'block is missing for dashboard_async' + Rails.log.error msg + raise msg + end + + config = { dashboard_id: id, + block: block } + + settings[:user_id] = User.current.id if !options.key?(:skip_user_id) || !options[:skip_user_id] + + if settings.present? + settings.each do |key, setting| + settings[key] = setting.reject(&:blank?).join(',') if setting.is_a? Array + + next if options[:exposed_params].blank? + + options[:exposed_params].each do |exposed_param| + if key == exposed_param + config[key] = settings[key] + settings.delete key + end + end + end + + unique_params = settings.flatten + unique_params += options[:unique_params].reject(&:blank?) if options[:unique_params].present? + + Rails.logger.debug "debug async_params for #{block}: unique_params=#{unique_params.inspect}" + config[:unique_key] = Digest::SHA256.hexdigest(unique_params.join('_')) + end + + Rails.logger.debug "debug async_params for #{block}: config=#{config.inspect}" + + config + end + + def project_id_can_change? + return true if new_record? || + dashboard_type != DashboardContentProject::TYPE_NAME || + !system_default_was || + project_id_was.present? + end + + private + + def clear_unused_block_settings + blocks = layout.values.flatten + layout_settings.keep_if { |block, _settings| blocks.include?(block) } + end + + def remove_unused_role_relations + return if !saved_change_to_visibility? || visibility == VISIBILITY_ROLES + + roles.clear + end + + def validate_roles + return if visibility != VISIBILITY_ROLES || roles.present? + + errors.add(:base, + [l(:label_role_plural), l('activerecord.errors.messages.blank')].join(' ')) + end + + def validate_system_default + return if new_record? || system_default_was == system_default || system_default? + + raise SystemDefaultChangeException + end + + def validate_project_system_default + return if project_id_can_change? + + raise ProjectSystemDefaultChangeException if project_id.present? + end + + def check_destroy_system_default + raise 'It is not allowed to delete dashboard, which is system default' unless destroyable? + end + + def dashboard_type_check + self.project_id = nil if dashboard_type == DashboardContentWelcome::TYPE_NAME + end + + def update_system_defaults + return unless system_default? && User.current.allowed_to?(:set_system_dashboards, project, global: true) + + scope = self.class + .where(dashboard_type: dashboard_type) + .where.not(id: id) + + scope = scope.where(project: project) if dashboard_type == DashboardContentProject::TYPE_NAME + + scope.update_all system_default: false + end + + # check if permissions changed and dashboard settings have to be corrected + def visibility_check + user = User.current + + return if system_default? || + user.allowed_to?(:share_dashboards, project, global: true) || + user.allowed_to?(:set_system_dashboards, project, global: true) + + # change to private + self.visibility = VISIBILITY_PRIVATE + end + + def validate_visibility + errors.add(:visibility, :must_be_for_everyone) if system_default? && visibility != VISIBILITY_PUBLIC + end + + def validate_name + return if name.blank? + + scope = self.class.visible.where(name: name) + if dashboard_type == DashboardContentProject::TYPE_NAME + scope = scope.project_only + scope = scope.where project_id: project_id + scope = scope.or(scope.where(project_id: nil)) if project_id.present? + else + scope = scope.welcome_only + end + + scope = scope.where.not(id: id) unless new_record? + errors.add(:name, :name_not_unique) if scope.count.positive? + end +end diff --git a/plugins/additionals/app/models/dashboard_content.rb b/plugins/additionals/app/models/dashboard_content.rb new file mode 100644 index 0000000..a03c93c --- /dev/null +++ b/plugins/additionals/app/models/dashboard_content.rb @@ -0,0 +1,111 @@ +class DashboardContent + include Redmine::I18n + + attr_accessor :user, :project + + MAX_MULTIPLE_OCCURS = 8 + DEFAULT_MAX_ENTRIES = 10 + RENDER_ASYNC_CACHE_EXPIRES_IN = 30 + + class << self + def types + descendants.map { |dc| dc::TYPE_NAME } + end + end + + def with_chartjs? + false + end + + def initialize(attr = {}) + self.user = attr[:user].presence || User.current + self.project = attr[:project].presence + end + + def groups + %w[top left right bottom] + end + + def block_definitions + { + 'issuequery' => { label: l(:label_query_with_name, l(:label_issue_plural)), + permission: :view_issues, + query_block: { + label: l(:label_issue_plural), + list_partial: 'issues/list', + class: IssueQuery, + link_helper: '_project_issues_path', + count_method: 'issue_count', + entries_method: 'issues', + entities_var: :issues, + with_project: true + }, + max_occurs: DashboardContent::MAX_MULTIPLE_OCCURS }, + 'text' => { label: l(:label_text), + max_occurs: MAX_MULTIPLE_OCCURS, + partial: 'dashboards/blocks/text' }, + 'news' => { label: l(:label_news_latest), + permission: :view_news }, + 'documents' => { label: l(:label_document_plural), + permission: :view_documents }, + 'my_spent_time' => { label: l(:label_my_spent_time), + permission: :log_time }, + 'feed' => { label: l(:label_additionals_feed), + max_occurs: DashboardContent::MAX_MULTIPLE_OCCURS, + async: { required_settings: %i[url], + cache_expires_in: 600, + skip_user_id: true, + partial: 'dashboards/blocks/feed' } } + } + end + + # Returns the available blocks + def available_blocks + return @available_blocks if defined? @available_blocks + + available_blocks = begin block_definitions.reject do |_block_name, block_specs| + block_specs.key?(:permission) && !user.allowed_to?(block_specs[:permission], project, global: true) || + block_specs.key?(:admin_only) && block_specs[:admin_only] && !user.admin? || + block_specs.key?(:if) && !block_specs[:if].call(project) + end + end + + @available_blocks = available_blocks.sort_by { |_k, v| v[:label] }.to_h + end + + def block_options(blocks_in_use = []) + options = [] + available_blocks.each do |block, block_options| + indexes = blocks_in_use.map do |n| + Regexp.last_match(2).to_i if n =~ /\A#{block}(__(\d+))?\z/ + end + indexes.compact! + + occurs = indexes.size + block_id = indexes.any? ? "#{block}__#{indexes.max + 1}" : block + disabled = (occurs >= (available_blocks[block][:max_occurs] || 1)) + block_id = nil if disabled + + options << [block_options[:label], block_id] + end + options + end + + def valid_block?(block, blocks_in_use = []) + block.present? && block_options(blocks_in_use).map(&:last).include?(block) + end + + def find_block(block) + block.to_s =~ /\A(.*?)(__\d+)?\z/ + name = Regexp.last_match(1) + available_blocks.key?(name) ? available_blocks[name].merge(name: name) : nil + end + + # Returns the default layout for a new dashboard + def default_layout + { + 'left' => ['legacy_left'], + 'right' => ['legacy_right'] + } + end +end diff --git a/plugins/additionals/app/models/dashboard_content_project.rb b/plugins/additionals/app/models/dashboard_content_project.rb new file mode 100644 index 0000000..9884135 --- /dev/null +++ b/plugins/additionals/app/models/dashboard_content_project.rb @@ -0,0 +1,52 @@ +class DashboardContentProject < DashboardContent + TYPE_NAME = 'ProjectDashboard'.freeze + + def block_definitions + blocks = super + + # legacy_left or legacy_right should not be moved to DashboardContent, + # because DashboardContent is used for areas in other plugins + blocks['legacy_left'] = { label: l(:label_dashboard_legacy_left), + no_settings: true } + + blocks['legacy_right'] = { label: l(:label_dashboard_legacy_right), + no_settings: true } + + blocks['projectinformation'] = { label: l(:label_project_information), + no_settings: true, + if: (lambda do |project| + project.description.present? || + project.homepage.present? || + project.visible_custom_field_values.any? { |o| o.value.present? } + end), + partial: 'dashboards/blocks/project_information' } + + blocks['projectissues'] = { label: l(:label_issues_summary), + no_settings: true, + permission: :view_issues, + partial: 'dashboards/blocks/project_issues' } + + blocks['projecttimeentries'] = { label: l(:label_time_tracking), + no_settings: true, + permission: :view_time_entries, + partial: 'dashboards/blocks/project_time_entries' } + + blocks['projectmembers'] = { label: l(:label_member_plural), + no_settings: true, + partial: 'projects/members_box' } + + blocks['projectsubprojects'] = { label: l(:label_subproject_plural), + no_settings: true, + partial: 'dashboards/blocks/project_subprojects' } + + blocks + end + + # Returns the default layout for a new dashboard + def default_layout + { + 'left' => %w[projectinformation projectissues projecttimeentries], + 'right' => %w[projectmembers projectsubprojects] + } + end +end diff --git a/plugins/additionals/app/models/dashboard_content_welcome.rb b/plugins/additionals/app/models/dashboard_content_welcome.rb new file mode 100644 index 0000000..0074c55 --- /dev/null +++ b/plugins/additionals/app/models/dashboard_content_welcome.rb @@ -0,0 +1,33 @@ +class DashboardContentWelcome < DashboardContent + TYPE_NAME = 'WelcomeDashboard'.freeze + + def block_definitions + blocks = super + + # legacy_left or legacy_right should not be moved to DashboardContent, + # because DashboardContent is used for areas in other plugins + blocks['legacy_left'] = { label: l(:label_dashboard_legacy_left), + no_settings: true } + + blocks['legacy_right'] = { label: l(:label_dashboard_legacy_right), + no_settings: true } + + blocks['welcome'] = { label: l(:setting_welcome_text), + no_settings: true, + partial: 'dashboards/blocks/welcome' } + + blocks['activity'] = { label: l(:label_activity), + async: { data_method: 'activity_dashboard_data', + partial: 'dashboards/blocks/activity' } } + + blocks + end + + # Returns the default layout for a new dashboard + def default_layout + { + 'left' => %w[welcome legacy_left], + 'right' => ['legacy_right'] + } + end +end diff --git a/plugins/additionals/app/models/dashboard_role.rb b/plugins/additionals/app/models/dashboard_role.rb new file mode 100644 index 0000000..9ecfff7 --- /dev/null +++ b/plugins/additionals/app/models/dashboard_role.rb @@ -0,0 +1,6 @@ +class DashboardRole < ActiveRecord::Base + include Redmine::SafeAttributes + + belongs_to :dashboard + belongs_to :role +end diff --git a/plugins/additionals/app/overrides/account/register.rb b/plugins/additionals/app/overrides/account/register.rb index 3f804be..419a744 100755 --- a/plugins/additionals/app/overrides/account/register.rb +++ b/plugins/additionals/app/overrides/account/register.rb @@ -1,5 +1,5 @@ Deface::Override.new virtual_path: 'account/register', name: 'add-invisble-captcha', insert_top: 'div.box', - original: 'e64d82c46cc3322e4d953aa119d1e71e81854158', + original: 'a9c303821376a8d83cba32654629d71cc3926a1d', partial: 'account/invisible_captcha' diff --git a/plugins/additionals/app/overrides/contacts/form.rb b/plugins/additionals/app/overrides/contacts/form.rb new file mode 100644 index 0000000..a1e8a9b --- /dev/null +++ b/plugins/additionals/app/overrides/contacts/form.rb @@ -0,0 +1,8 @@ +unless Redmine::Plugin.installed? 'redmine_servicedesk' + # redmine_contacts does not provide hook + Deface::Override.new virtual_path: 'contacts/_form', + name: 'contacts-form-hook', + insert_bottom: 'div#contact_data', + original: 'df6cae24cfd26e5299c45c427fbbd4e5f23c313e', + partial: 'hooks/view_contacts_form' +end diff --git a/plugins/additionals/app/overrides/custom_fields/formats.rb b/plugins/additionals/app/overrides/custom_fields/formats.rb new file mode 100644 index 0000000..cc8014c --- /dev/null +++ b/plugins/additionals/app/overrides/custom_fields/formats.rb @@ -0,0 +1,6 @@ +Deface::Override.new virtual_path: 'custom_fields/formats/_text', + name: 'custom_fields-formats-text', + replace: 'erb[silent]:contains(\'if @custom_field.class.name == "IssueCustomField"\')', + original: '5e0fbf8e8156bf1514cbada3dbaca9afc3c19bbb', + closing_selector: "erb[silent]:contains('end')", + partial: 'custom_fields/formats/additionals_text.html.slim' diff --git a/plugins/additionals/app/overrides/issues/edit.rb b/plugins/additionals/app/overrides/issues/edit.rb new file mode 100644 index 0000000..3cf7ae6 --- /dev/null +++ b/plugins/additionals/app/overrides/issues/edit.rb @@ -0,0 +1,5 @@ +Deface::Override.new virtual_path: 'issues/_edit', + name: 'edit-issue-permission', + replace: 'erb[silent]:contains("User.current.allowed_to?(:log_time, @project)")', + original: '98560fb12bb71f775f2a7fd1884c97f8cd632cd3', + text: '<% if User.current.allowed_to?(:log_time, @project) && @issue.log_time_allowed? %>' diff --git a/plugins/additionals/app/overrides/issues/list.rb b/plugins/additionals/app/overrides/issues/list.rb new file mode 100644 index 0000000..013f165 --- /dev/null +++ b/plugins/additionals/app/overrides/issues/list.rb @@ -0,0 +1,5 @@ +Deface::Override.new virtual_path: 'issues/_list', + name: 'list-issue-back-url', + replace: 'erb[loud]:contains("hidden_field_tag \'back_url\'")', + original: '6652d55078bb57ac4614e456b01f8a203b8096ec', + text: '<%= query_list_back_url_tag @project %>' diff --git a/plugins/additionals/app/overrides/issues/show.rb b/plugins/additionals/app/overrides/issues/show.rb index deea066..a04a3bc 100755 --- a/plugins/additionals/app/overrides/issues/show.rb +++ b/plugins/additionals/app/overrides/issues/show.rb @@ -1,5 +1,10 @@ +Deface::Override.new virtual_path: 'issues/_action_menu', + name: 'show-issue-log-time', + replace: 'erb[loud]:contains("User.current.allowed_to?(:log_time, @project)")', + original: '4bbf065b9f960687e07f76e7232eb21bf183a981', + partial: 'issues/additionals_action_menu_log_time' Deface::Override.new virtual_path: 'issues/_action_menu', name: 'add-issue-assign-to-me', insert_bottom: 'div.contextual', - original: 'c0a30490bb9ac5c5644e674319f17e40c57034d8', + original: '44ef032156db0dfdb67301fdb9ef8901abeca18a', partial: 'issues/additionals_action_menu' diff --git a/plugins/additionals/app/overrides/layouts/base.rb b/plugins/additionals/app/overrides/layouts/base.rb deleted file mode 100755 index 4a07080..0000000 --- a/plugins/additionals/app/overrides/layouts/base.rb +++ /dev/null @@ -1,5 +0,0 @@ -Deface::Override.new virtual_path: 'layouts/base', - name: 'add-body-header', - insert_before: 'div#wrapper', - original: '4af81ed701989727953cea2e376c9d83665d7eb2', - partial: 'additionals/global_body_header' diff --git a/plugins/additionals/app/overrides/reports/simple.rb b/plugins/additionals/app/overrides/reports/simple.rb new file mode 100644 index 0000000..12cb3ad --- /dev/null +++ b/plugins/additionals/app/overrides/reports/simple.rb @@ -0,0 +1,5 @@ +Deface::Override.new virtual_path: 'reports/_simple', + name: 'report-simple-user-scope', + insert_before: 'erb[silent]:contains("rows.empty?")', + original: '0c85cc752700d7f2bf08b3b9b30f59d8eddc443b', + partial: 'reports/additionals_simple' diff --git a/plugins/additionals/app/overrides/roles/form.rb b/plugins/additionals/app/overrides/roles/form.rb index e019ae2..163f3fe 100755 --- a/plugins/additionals/app/overrides/roles/form.rb +++ b/plugins/additionals/app/overrides/roles/form.rb @@ -1,5 +1,5 @@ Deface::Override.new virtual_path: 'roles/_form', name: 'roles-form-hide', insert_before: 'p.manage_members_shown', - original: 'b2a317f49e0b65ae506c8871f0c2bcc3e8098766', + original: '7413482e01a07b5615be1900b974fee87224cb47', partial: 'roles/additionals_form' diff --git a/plugins/additionals/app/overrides/users/show.rb b/plugins/additionals/app/overrides/users/show.rb index 0fe2973..dbe63d8 100755 --- a/plugins/additionals/app/overrides/users/show.rb +++ b/plugins/additionals/app/overrides/users/show.rb @@ -1,10 +1,12 @@ -Deface::Override.new virtual_path: 'users/show', - name: 'user-show-info-hook', - insert_top: 'div.splitcontentleft ul:first-child', - original: 'aff8d775275e3f33cc45d72b8e2896144be4beff', - partial: 'hooks/view_users_show' -Deface::Override.new virtual_path: 'users/show', - name: 'user-contextual-hook', - insert_bottom: 'div.contextual', - original: '9d6a7ad6ba0addc68c6b4f6c3b868511bc8eb542', - partial: 'hooks/view_users_contextual' +unless Redmine::Plugin.installed? 'redmine_hrm' + Deface::Override.new virtual_path: 'users/show', + name: 'user-show-info-hook', + insert_top: 'div.splitcontentleft ul:first-child', + original: '743d616ab7942bb6bc65bd00626b6a5143247a37', + partial: 'hooks/view_users_show_info' + Deface::Override.new virtual_path: 'users/show', + name: 'user-contextual-hook', + insert_bottom: 'div.contextual', + original: '9d6a7ad6ba0addc68c6b4f6c3b868511bc8eb542', + partial: 'hooks/view_users_show_contextual' +end diff --git a/plugins/additionals/app/overrides/welcome/index.rb b/plugins/additionals/app/overrides/welcome/index.rb deleted file mode 100755 index 42ad6c3..0000000 --- a/plugins/additionals/app/overrides/welcome/index.rb +++ /dev/null @@ -1,15 +0,0 @@ -Deface::Override.new virtual_path: 'welcome/index', - name: 'add-welcome-bottom-content', - insert_after: 'div.splitcontentright', - original: 'dd470844bcaa4d7c9dc66e70e6c0c843d42969bf', - partial: 'welcome/overview_bottom' -Deface::Override.new virtual_path: 'welcome/index', - name: 'add-welcome-top-content', - insert_before: 'div.splitcontentleft', - original: 'e7de0a2e88c5ccb4d1feb7abac239e4b669babed', - partial: 'welcome/overview_top' -Deface::Override.new virtual_path: 'welcome/index', - name: 'remove-welcome-news', - replace: 'div.news', - original: '163f5df8f0cb2d5009d7f57ad38174ed29201a1a', - partial: 'welcome/overview_news' diff --git a/plugins/additionals/app/overrides/wiki/show.rb b/plugins/additionals/app/overrides/wiki/show.rb deleted file mode 100755 index 4ce41f4..0000000 --- a/plugins/additionals/app/overrides/wiki/show.rb +++ /dev/null @@ -1,5 +0,0 @@ -Deface::Override.new virtual_path: 'wiki/show', - name: 'addto-wiki-show', - insert_before: 'div.contextual', - original: '6b0cb1646d5e2cb23feee1805949e266036581e6', - partial: 'wiki/show_additionals' diff --git a/plugins/additionals/app/overrides/wiki/sidebar.rb b/plugins/additionals/app/overrides/wiki/sidebar.rb deleted file mode 100755 index 3db5655..0000000 --- a/plugins/additionals/app/overrides/wiki/sidebar.rb +++ /dev/null @@ -1,5 +0,0 @@ -Deface::Override.new virtual_path: 'wiki/_sidebar', - name: 'addto-wiki-sidebar', - insert_after: 'ul', - original: '07a5375c015a7d96826c9977c4d8889c4a98bb49', - partial: 'wiki/global_sidebar' diff --git a/plugins/additionals/app/views/account/_invisible_captcha.html.slim b/plugins/additionals/app/views/account/_invisible_captcha.html.slim index 0c9c490..e22a941 100755 --- a/plugins/additionals/app/views/account/_invisible_captcha.html.slim +++ b/plugins/additionals/app/views/account/_invisible_captcha.html.slim @@ -1,2 +1,2 @@ - if Additionals.setting?(:invisible_captcha) - = invisible_captcha + = f.invisible_captcha :url, autocomplete: 'off' diff --git a/plugins/additionals/app/views/account/_login_text.html.slim b/plugins/additionals/app/views/account/_login_text.html.slim index 7d03053..56e7b55 100755 --- a/plugins/additionals/app/views/account/_login_text.html.slim +++ b/plugins/additionals/app/views/account/_login_text.html.slim @@ -1,5 +1,5 @@ -- login_text = Additionals.settings[:account_login_bottom] +- login_text = Additionals.setting :account_login_bottom - if login_text.present? br .login-additionals - = textilizable(login_text) + = textilizable login_text diff --git a/plugins/additionals/app/views/additionals/_body_bottom.html.slim b/plugins/additionals/app/views/additionals/_body_bottom.html.slim index 28c4f22..b045725 100755 --- a/plugins/additionals/app/views/additionals/_body_bottom.html.slim +++ b/plugins/additionals/app/views/additionals/_body_bottom.html.slim @@ -1,9 +1,17 @@ -- footer = Additionals.settings[:global_footer] +- footer = Additionals.setting(:global_footer) - if footer.present? .additionals-footer = textilizable(footer) + - if @additionals_help_items.present? javascript: $(function() { - $('a.help').parent().append("
"); + $('a.help').parent().append(" "); + }); + +- if Additionals.setting?(:open_external_urls) + javascript: + $(function() { + $('a.external').attr({ 'target': '_blank', + 'rel': 'noopener noreferrer'}); }); diff --git a/plugins/additionals/app/views/additionals/_global_body_header.slim b/plugins/additionals/app/views/additionals/_body_top.slim old mode 100755 new mode 100644 similarity index 100% rename from plugins/additionals/app/views/additionals/_global_body_header.slim rename to plugins/additionals/app/views/additionals/_body_top.slim diff --git a/plugins/additionals/app/views/additionals/_chart_table_values.html.slim b/plugins/additionals/app/views/additionals/_chart_table_values.html.slim new file mode 100644 index 0000000..286d5d0 --- /dev/null +++ b/plugins/additionals/app/views/additionals/_chart_table_values.html.slim @@ -0,0 +1,18 @@ +table.list.issue-report.table-of-values + = title_with_fontawesome l(:label_table_of_values), 'far fa-list-alt', 'caption' + thead + tr + th = @chart[:label] + th = l :label_total + tbody + - options = { set_filter: 1 } + - @chart[:filters].each do |line| + - if line[:filter] + - options.merge! line[:filter] + tr class="#{cycle 'odd', 'even'}" + td.name class="#{line[:id].to_s == '0' ? 'summary' : ''}" + - if line[:filter].nil? + = line[:name] + - else + = link_to line[:name], send(@chart[:value_link_method], @project, options) + td = line[:count] diff --git a/plugins/additionals/app/views/additionals/_content.html.slim b/plugins/additionals/app/views/additionals/_content.html.slim deleted file mode 100755 index 0b58a93..0000000 --- a/plugins/additionals/app/views/additionals/_content.html.slim +++ /dev/null @@ -1,5 +0,0 @@ -- unless (controller_name == 'account' && action_name == 'login') || \ - (controller_name == 'my') || \ - (controller_name == 'account' && action_name == 'lost_password') - - if Additionals.setting?(:add_go_to_top) - a.gototop[href="#gototop"] = l(:label_go_to_top) diff --git a/plugins/additionals/app/views/additionals/_export_options.html.slim b/plugins/additionals/app/views/additionals/_export_options.html.slim index fa50638..4a3214f 100755 --- a/plugins/additionals/app/views/additionals/_export_options.html.slim +++ b/plugins/additionals/app/views/additionals/_export_options.html.slim @@ -2,25 +2,34 @@ div id="#{export_format}-export-options" style="display: none" h3.title = l(:label_export_options, export_format: export_format.upcase) = form_tag(url, method: :get, id: "#{export_format}-export-form") do - - if @query.available_filters.key?('description') - = query_as_hidden_field_tags @query, [:description] - else - = query_as_hidden_field_tags @query - p - label - = radio_button_tag 'c[]', '', true - = l(:description_selected_columns) - br - label - = radio_button_tag 'c[]', 'all_inline' - = l(:description_all_columns) + = query_as_hidden_field_tags @query + - if defined?(selected_columns_only) && selected_columns_only + = hidden_field_tag 'c[]', '' + = l(:description_selected_columns) + - else + p + label + = radio_button_tag 'c[]', '', true + = l(:description_selected_columns) + br + label + = radio_button_tag 'c[]', 'all_inline' + = l(:description_all_columns) + + hr + - if @query.available_filters.key?('description') p label = check_box_tag 'c[]', 'description', @query.has_column?(:description) = l(:field_description) - - if Rails.version >= '5.2' - = export_csv_encoding_select_tag + - if defined?(with_last_notes) && with_last_notes + label + = check_box_tag 'c[]', 'last_notes', @query.has_column?(:last_notes) + = l(:label_last_notes) + + = export_csv_encoding_select_tag + p.buttons = submit_tag l(:button_export), name: nil, onclick: 'hideModal(this);' ' diff --git a/plugins/additionals/app/views/additionals/_global_sidebar.html.slim b/plugins/additionals/app/views/additionals/_global_sidebar.html.slim deleted file mode 100755 index 00b5775..0000000 --- a/plugins/additionals/app/views/additionals/_global_sidebar.html.slim +++ /dev/null @@ -1,5 +0,0 @@ -- sidebar = Additionals.settings[:global_sidebar] -- if sidebar.present? - br - .sidebar-additionals - = textilizable(sidebar) diff --git a/plugins/additionals/app/views/additionals/_h2_with_query_search.html.slim b/plugins/additionals/app/views/additionals/_h2_with_query_search.html.slim index f54424f..3af60ee 100755 --- a/plugins/additionals/app/views/additionals/_h2_with_query_search.html.slim +++ b/plugins/additionals/app/views/additionals/_h2_with_query_search.html.slim @@ -4,4 +4,8 @@ h2 class="#{classes}" = @query.new_record? ? l(title) : h(@query.name) span.additionals-live-search - = text_field_tag(:search, q, autocomplete: 'off', class: 'live-search-field', placeholder: l(placeholder)) + = text_field_tag(:search, + q, + autocomplete: 'off', + class: 'live-search-field', + placeholder: l(:label_query_name_search)) diff --git a/plugins/additionals/app/views/additionals/_html_head.html.slim b/plugins/additionals/app/views/additionals/_html_head.html.slim index 7877b16..1680cb6 100755 --- a/plugins/additionals/app/views/additionals/_html_head.html.slim +++ b/plugins/additionals/app/views/additionals/_html_head.html.slim @@ -1,9 +1,6 @@ - additionals_top_menu_setup -- if Additionals.settings[:external_urls].to_i > 0 - = javascript_include_tag('redirect', plugin: 'additionals') -- if Additionals.settings[:external_urls].to_i == 2 - = javascript_include_tag('noreferrer', plugin: 'additionals') -= additionals_library_load(:font_awesome) += additionals_library_load :font_awesome = stylesheet_link_tag 'additionals', plugin: 'additionals' -- if User.current.try(:hrm_user_type_id).nil? += javascript_include_tag 'additionals', plugin: 'additionals' +- unless Redmine::Plugin.installed? 'redmine_hrm' - render_custom_top_menu_item diff --git a/plugins/additionals/app/views/additionals/_select2_ajax_call.js.slim b/plugins/additionals/app/views/additionals/_select2_ajax_call.js.slim index 76c5e36..20d3e87 100755 --- a/plugins/additionals/app/views/additionals/_select2_ajax_call.js.slim +++ b/plugins/additionals/app/views/additionals/_select2_ajax_call.js.slim @@ -1,31 +1,28 @@ - options = {} if options.nil? javascript: - $("##{field_id}").select2({ - ajax: { - url: "#{ajax_url}", - dataType: 'json', - delay: 250, - data: function(params) { - return { - q: params.term - }; + $(function() { + $("#{defined?(field_id) ? ('#' + field_id) : ('.' + field_class)}").select2({ + ajax: { + url: "#{ajax_url}", + dataType: 'json', + delay: 250, + data: function(params) { + return { + q: params.term, + }; + }, + processResults: function(data, params) { + return { + results: data + }; + }, + cache: true }, - processResults: function(data, params) { - return { - results: data - }; - }, - cache: true - }, - placeholder: "#{options[:placeholder].presence}", - allowClear: #{options[:allow_clear].present? && options[:allow_clear] ? 'true' : 'false'}, - minimumInputLength: 0, - width: '60%', - templateResult: formatState - }); - - function formatState(opt) { - if (opt.loading) return opt.name; - var $opt = $('' + opt.name_with_icon + ''); - return $opt; - }; + placeholder: "#{options[:placeholder].presence}", + allowClear: #{options[:allow_clear].present? && options[:allow_clear] ? 'true' : 'false'}, + minimumInputLength: 0, + width: "#{options[:width].presence || '60%'}", + templateResult: #{options[:template_result].presence || 'formatNameWithIcon'}, + #{options[:template_selection].present? ? ('templateSelection: ' + options[:template_selection]) : nil} + }) + }) diff --git a/plugins/additionals/app/views/additionals/_settings_list_defaults.html.slim b/plugins/additionals/app/views/additionals/_settings_list_defaults.html.slim index 0327b85..b21b5c9 100755 --- a/plugins/additionals/app/views/additionals/_settings_list_defaults.html.slim +++ b/plugins/additionals/app/views/additionals/_settings_list_defaults.html.slim @@ -2,15 +2,11 @@ fieldset.box legend = l(:additionals_query_list_defaults) - setting_name_columns = "#{query_type}_list_defaults" - query = query_class.new(@settings[setting_name_columns.to_sym]) - - if Redmine::VERSION.to_s >= '4' - .default-query-settings-label-redmine4 - = render_query_columns_selection(query, name: "settings[#{setting_name_columns}][column_names]") - - else - .default-query-settings-label - = render_query_columns_selection(query, name: "settings[#{setting_name_columns}][column_names]") + .default-query-settings-label + = render_query_columns_selection(query, name: "settings[#{setting_name_columns}][column_names]") - columns = query_class.new.available_totalable_columns -- if columns.count > 0 +- if columns.count.positive? fieldset.box legend = l(:additionals_query_list_default_totals) diff --git a/plugins/additionals/app/views/additionals/_tag_list.html.slim b/plugins/additionals/app/views/additionals/_tag_list.html.slim index e08fb0a..ec9858c 100755 --- a/plugins/additionals/app/views/additionals/_tag_list.html.slim +++ b/plugins/additionals/app/views/additionals/_tag_list.html.slim @@ -6,7 +6,9 @@ ' : - if defined?(editable) && editable #tags-data - = additionals_tag_links(entry.tags, tags_without_color: defined?(tags_without_color) ? tags_without_color : false) + = additionals_tag_links(entry.tags, + project: @project, + tags_without_color: defined?(tags_without_color) ? tags_without_color : false) ' span.contextual = link_to l(:label_edit_tags), @@ -15,7 +17,7 @@ id: 'edit_tags_link' #edit_tags_form style="display: none;" - = form_tag(update_url, method: :put, multipart: true ) do + = form_tag(update_url, method: :put, multipart: true) do = render partial: 'tags_form' ' = submit_tag l(:button_save), class: 'button-small' @@ -23,4 +25,6 @@ = link_to l(:button_cancel), {}, onclick: "$('#edit_tags_form').hide(); $('#tags-data').show(); return false;" - else - = additionals_tag_links(entry.tags, tags_without_color: defined?(tags_without_color) ? tags_without_color : false) + = additionals_tag_links(entry.tags, + project: @project, + tags_without_color: defined?(tags_without_color) ? tags_without_color : false) diff --git a/plugins/additionals/app/views/additionals/charts/_pie_with_value_table.slim b/plugins/additionals/app/views/additionals/charts/_pie_with_value_table.slim new file mode 100644 index 0000000..2339949 --- /dev/null +++ b/plugins/additionals/app/views/additionals/charts/_pie_with_value_table.slim @@ -0,0 +1,46 @@ +.additionals-chart-wrapper + .additionals-chart-left + canvas id="#{@chart[:id]}" style="width: #{@chart[:width]}px; height: #{@chart[:height]}px;" + .additionals-table-of-values + = render partial: 'additionals/chart_table_values' + +.clear-both + +javascript: + const pie_chart_#{{@chart[:id]}} = new Chart(document.getElementById("#{@chart[:id]}"), { + type: 'pie', + data: { + label_ids: #{raw json_escape(@chart[:label_ids])}, + labels: #{raw json_escape(@chart[:labels])}, + datasets: #{raw json_escape(@chart[:datasets])} + }, + options: { + responsive: true, + onClick: function(c, i) { + e = i[0]; + if (e !== undefined && #{{@chart[:filter_path].present? ? 1 : 0}} == 1 ) { + var activePoints = pie_chart_#{{@chart[:id]}}.getElementAtEvent(c); + var label_id = this.data.label_ids[activePoints[0]._index]; + window.open("#{{@chart[:filter_path]}}" + label_id); + } + }, + plugins: { + colorschemes: { + scheme: "#{@chart[:color_schema]}", + fillAlpha: 0.8, + }, + datalabels: { + formatter: (value, ctx) => { + let sum = 0; + let dataArr = ctx.chart.data.datasets[0].data; + dataArr.map(data => { + sum += data; + }); + let percentage = (value*100 / sum).toFixed(0)+"%"; + return percentage; + }, + color: '#000', + } + } + } + }); diff --git a/plugins/additionals/app/views/additionals/settings/_additionals.html.slim b/plugins/additionals/app/views/additionals/settings/_additionals.html.slim index df9c460..ab3d683 100755 --- a/plugins/additionals/app/views/additionals/settings/_additionals.html.slim +++ b/plugins/additionals/app/views/additionals/settings/_additionals.html.slim @@ -1,4 +1,3 @@ -- @settings = ActionController::Parameters.new(@settings) unless Rails.version >= '5.2' ' Need Help? : = link_to(l(:label_additionals_doc), 'https://additionals.readthedocs.io/en/latest/', diff --git a/plugins/additionals/app/views/additionals/settings/_general.html.slim b/plugins/additionals/app/views/additionals/settings/_general.html.slim index 53071d3..1fb0d72 100755 --- a/plugins/additionals/app/views/additionals/settings/_general.html.slim +++ b/plugins/additionals/app/views/additionals/settings/_general.html.slim @@ -1,39 +1,47 @@ -br -h3 = l(:label_content_plural) +fieldset.settings + legend = l(:label_content_plural) -p - = content_tag(:label, l(:label_account_login)) - = text_area_tag 'settings[account_login_bottom]', @settings[:account_login_bottom], class: 'wiki-edit', rows: 10 - em.info - = l(:account_login_info) -p - = content_tag(:label, l(:label_global_sidebar)) - = text_area_tag 'settings[global_sidebar]', @settings[:global_sidebar], class: 'wiki-edit', rows: 10 - em.info - = l(:global_sidebar_info) -p - = content_tag(:label, l(:label_global_footer)) - = text_area_tag 'settings[global_footer]', @settings[:global_footer], class: 'wiki-edit', rows: 5 - em.info - = l(:global_footer_info) + p + = additionals_settings_textarea :account_login_bottom + em.info + = l(:account_login_info) + p + = additionals_settings_textarea :global_sidebar + em.info + = l(:global_sidebar_info) + p + = additionals_settings_textarea :global_footer + em.info + = l(:global_footer_info) -br -h3 = l(:label_setting_plural) -p - = content_tag(:label, l(:label_external_urls)) - = select_tag 'settings[external_urls]', - options_for_select({ l(:external_url_default) => '0', - l(:external_url_new_window) => '1', - l(:external_url_noreferrer) => '2' }, @settings['external_urls']) - em.info - = t(:external_urls_info_html) -p - = content_tag(:label, l(:label_add_go_to_top)) - = check_box_tag 'settings[add_go_to_top]', 1, @settings[:add_go_to_top].to_i == 1 - em.info - = t(:add_go_to_top_info) -p - = content_tag(:label, l(:label_legacy_smiley_support)) - = check_box_tag 'settings[legacy_smiley_support]', 1, @settings[:legacy_smiley_support].to_i == 1 - em.info - = t(:legacy_smiley_support_info_html) +fieldset.settings + legend = l(:label_settings) + + p + = additionals_settings_checkbox :open_external_urls + em.info + = t(:open_external_urls_info) + p + = additionals_settings_checkbox :add_go_to_top + em.info + = t(:add_go_to_top_info) + p + = additionals_settings_checkbox :legacy_smiley_support + em.info + = t(:legacy_smiley_support_info_html) + +fieldset.settings + legend = l(:label_disabled_modules) + + p + = tag.label l(:label_disabled_modules) + = hidden_field_tag('settings[disabled_modules][]', '') + - Redmine::AccessControl.available_project_modules_all.sort.each do |m| + label.block + - value = @settings[:disabled_modules].present? ? @settings[:disabled_modules].include?(m.to_s) : false + = check_box_tag('settings[disabled_modules][]', m, value, id: nil) + = l_or_humanize(m, prefix: 'project_module_') + + br + em.info + = l(:disabled_modules_info) diff --git a/plugins/additionals/app/views/additionals/settings/_issues.html.slim b/plugins/additionals/app/views/additionals/settings/_issues.html.slim index 290db0c..6b13902 100755 --- a/plugins/additionals/app/views/additionals/settings/_issues.html.slim +++ b/plugins/additionals/app/views/additionals/settings/_issues.html.slim @@ -1,44 +1,29 @@ +em.info = t(:top_rules_help) + br -h3 = l(:label_content_plural) p - = content_tag(:label, l(:label_new_ticket_message)) - = text_area_tag 'settings[new_ticket_message]', @settings[:new_ticket_message], class: 'wiki-edit', rows: 10 + = additionals_settings_textarea :new_ticket_message em.info = l(:new_ticket_message_info) -br -hr - -h3 = l(:label_setting_plural) -.info = t(:top_rules_help) - br p - = content_tag(:label, l(:label_new_issue_on_profile)) - = check_box_tag 'settings[new_issue_on_profile]', 1, @settings[:new_issue_on_profile].to_i == 1 + = additionals_settings_checkbox :new_issue_on_profile p - = content_tag(:label, l(:label_issue_assign_to_me)) - = check_box_tag 'settings[issue_assign_to_me]', 1, @settings[:issue_assign_to_me].to_i == 1 + = additionals_settings_checkbox :issue_assign_to_me p - = content_tag(:label, l(:label_issue_change_status_in_sidebar)) - = check_box_tag 'settings[issue_change_status_in_sidebar]', 1, @settings[:issue_change_status_in_sidebar].to_i == 1 + = additionals_settings_checkbox :issue_change_status_in_sidebar p - = content_tag(:label, l(:label_issue_autowatch_involved)) - = check_box_tag 'settings[issue_autowatch_involved]', 1, @settings[:issue_autowatch_involved].to_i == 1 + = additionals_settings_checkbox :issue_autowatch_involved p - = content_tag(:label, l(:label_rule_issue_close_with_open_children)) - = check_box_tag 'settings[issue_close_with_open_children]', 1, @settings[:issue_close_with_open_children].to_i == 1 -p - = content_tag(:label, l(:label_rule_issue_freezed_with_close)) - = check_box_tag 'settings[issue_freezed_with_close]', 1, @settings[:issue_freezed_with_close].to_i == 1 + = additionals_settings_checkbox :issue_freezed_with_close em.info = t(:rule_issue_freezed_with_close_info) br - rule_status = IssueStatus.sorted p - = content_tag(:label, l(:label_rule_issue_status_change)) - = check_box_tag 'settings[issue_status_change]', 1, @settings[:issue_status_change].to_i == 1 + = additionals_settings_checkbox :issue_status_change span[style="vertical-align: top; margin-left: 15px;"] = l(:field_status) | x: @@ -58,8 +43,7 @@ em.info = t(:rule_issue_status_change_info) br br p - = content_tag(:label, l(:label_rule_issue_current_user_status)) - = check_box_tag 'settings[issue_current_user_status]', 1, @settings[:issue_current_user_status].to_i == 1 + = additionals_settings_checkbox :issue_current_user_status span[style="vertical-align: top; margin-left: 15px;"] = l(:field_status) | x: @@ -72,8 +56,7 @@ em.info = t(:rule_issue_current_user_status_info_html) br br p - = content_tag(:label, l(:label_rule_issue_auto_assign)) - = check_box_tag 'settings[issue_auto_assign]', 1, @settings[:issue_auto_assign].to_i == 1 + = additionals_settings_checkbox :issue_auto_assign span[style="vertical-align: top; margin-left: 15px;"] = l(:field_status) | x: @@ -92,8 +75,7 @@ em.info = t(:rule_issue_auto_assign_info) br br p - = content_tag(:label, l(:label_rule_issue_timelog_required)) - = check_box_tag 'settings[issue_timelog_required]', 1, @settings[:issue_timelog_required].to_i == 1 + = additionals_settings_checkbox :issue_timelog_required span[style="vertical-align: top; margin-left: 15px;"] = l(:label_tracker_plural) | : diff --git a/plugins/additionals/app/views/additionals/settings/_macros.html.slim b/plugins/additionals/app/views/additionals/settings/_macros.html.slim index 003273b..bfa0ed4 100755 --- a/plugins/additionals/app/views/additionals/settings/_macros.html.slim +++ b/plugins/additionals/app/views/additionals/settings/_macros.html.slim @@ -4,7 +4,7 @@ em.info br p - = content_tag(:label, l(:label_hidden_macros_in_toolbar)) + = tag.label l(:label_hidden_macros_in_toolbar) = hidden_field_tag('settings[hidden_macros_in_toolbar][]', '') - @available_macros = AdditionalsMacro.all(only_names: true).each do |m| label.block diff --git a/plugins/additionals/app/views/additionals/settings/_menu.html.slim b/plugins/additionals/app/views/additionals/settings/_menu.html.slim index d0dccb1..009bd3b 100755 --- a/plugins/additionals/app/views/additionals/settings/_menu.html.slim +++ b/plugins/additionals/app/views/additionals/settings/_menu.html.slim @@ -1,30 +1,27 @@ -.info = t(:label_top_menu_help_html) +.info = t :label_top_menu_help_html br -h3 = l(:label_custom_menu_items) +h3 = l :label_custom_menu_items - 5.times do |i| fieldset legend - b = "#{l(:label_menu_entry)} ##{i + 1}" + b = "#{l :label_menu_entry} ##{i + 1}" div p - label = h l(:field_name) - = text_field_tag('settings[custom_menu' + i.to_s + '_name]', @settings['custom_menu' + i.to_s + '_name'], size: 40) + = additionals_settings_textfield "custom_menu#{i}_name".to_sym, label: l(:field_name), size: 40 p - label = h l(:field_url) - = text_field_tag('settings[custom_menu' + i.to_s + '_url]', @settings['custom_menu' + i.to_s + '_url'], size: 80) + = additionals_settings_textfield "custom_menu#{i}_url".to_sym, label: l(:field_url), size: 80 p - label = h l(:field_title) - = text_field_tag('settings[custom_menu' + i.to_s + '_title]', @settings['custom_menu' + i.to_s + '_title'], size: 80) + = additionals_settings_textfield "custom_menu#{i}_title".to_sym, label: l(:field_title), size: 80 i | ( - = l(:label_optional) + = l :label_optional | ) p label = h l(:label_permissions) - - permission_field = 'custom_menu' + i.to_s + '_roles' + - permission_field = "custom_menu#{i}_roles" - menu_roles = Struct.new(:id, :name) - = select_tag('settings[' + permission_field + ']', + = select_tag("settings[#{permission_field}]", options_from_collection_for_select(Role.sorted.collect { |m| menu_roles.new(m.id, m.name) }, :id, :name, @@ -33,12 +30,12 @@ h3 = l(:label_custom_menu_items) em.info = l(:menu_roles_info) br -h3 = l(:label_setting_plural) + +h3 = l :label_settings + p - = content_tag(:label, l(:label_remove_help)) - = check_box_tag 'settings[remove_help]', 1, @settings[:remove_help].to_i == 1 - em.info = l(:remove_help_info) + = additionals_settings_checkbox :remove_help + em.info = l :remove_help_info p - = content_tag(:label, l(:label_remove_mypage)) - = check_box_tag 'settings[remove_mypage]', 1, @settings[:remove_mypage].to_i == 1 - em.info = l(:remove_mypage_info) + = additionals_settings_checkbox :remove_mypage + em.info = l :remove_mypage_info diff --git a/plugins/additionals/app/views/additionals/settings/_overview.html.slim b/plugins/additionals/app/views/additionals/settings/_overview.html.slim deleted file mode 100755 index 167b252..0000000 --- a/plugins/additionals/app/views/additionals/settings/_overview.html.slim +++ /dev/null @@ -1,29 +0,0 @@ -.info = t(:top_overview_help) - -br -h3 = l(:label_content_plural) - -p - = content_tag(:label, l(:label_overview_right)) - = text_area_tag 'settings[overview_right]', @settings[:overview_right], class: 'wiki-edit', rows: 10 - em.info - = l(:overview_right_info) -p - = content_tag(:label, l(:label_overview_top)) - = text_area_tag 'settings[overview_top]', @settings[:overview_top], class: 'wiki-edit', rows: 10 - em.info - = l(:overview_top_info) -p - = content_tag(:label, l(:label_overview_bottom)) - = text_area_tag 'settings[overview_bottom]', @settings[:overview_bottom], class: 'wiki-edit', rows: 10 - em.info - = l(:overview_bottom_info) - -br -h3 = l(:label_setting_plural) - -p - = content_tag(:label, l(:label_remove_news)) - = check_box_tag 'settings[remove_news]', 1, @settings[:remove_news].to_i == 1 - em.info - = l(:remove_news_info) diff --git a/plugins/additionals/app/views/additionals/settings/_projects.html.slim b/plugins/additionals/app/views/additionals/settings/_projects.html.slim deleted file mode 100755 index 2bb7800..0000000 --- a/plugins/additionals/app/views/additionals/settings/_projects.html.slim +++ /dev/null @@ -1,26 +0,0 @@ -.info = t(:top_projects_help) -br - -p - = content_tag(:label, l(:label_project_overview_content)) - = text_area_tag 'settings[project_overview_content]', - @settings[:project_overview_content], - class: 'wiki-edit', rows: 10 - em.info - = l(:project_overview_content_info) - -hr - -p - = content_tag(:label, l(:label_disabled_modules)) - = hidden_field_tag('settings[disabled_modules][]', '') - - Redmine::AccessControl.available_project_modules_all.each do |m| - label.block - - value = @settings[:disabled_modules].present? ? @settings[:disabled_modules].include?(m.to_s) : false - = check_box_tag('settings[disabled_modules][]', m, value, id: nil) - = l_or_humanize(m, prefix: 'project_module_') - - br - - em.info - = l(:disabled_modules_info) diff --git a/plugins/additionals/app/views/additionals/settings/_users.html.slim b/plugins/additionals/app/views/additionals/settings/_users.html.slim index ca81b60..60bf889 100755 --- a/plugins/additionals/app/views/additionals/settings/_users.html.slim +++ b/plugins/additionals/app/views/additionals/settings/_users.html.slim @@ -1,10 +1,5 @@ -br -h3 = l(:label_user_plural) p - = content_tag(:label, l(:label_invisible_captcha)) - = check_box_tag 'settings[invisible_captcha]', - 1, - @settings[:invisible_captcha].to_i == 1, - disabled: (true unless Setting.self_registration?) + = additionals_settings_checkbox :invisible_captcha, + disabled: (true unless Setting.self_registration?) em.info = t(:invisible_captcha_info_html) diff --git a/plugins/additionals/app/views/additionals/settings/_web_apis.html.slim b/plugins/additionals/app/views/additionals/settings/_web_apis.html.slim index eea4032..ef6ea34 100755 --- a/plugins/additionals/app/views/additionals/settings/_web_apis.html.slim +++ b/plugins/additionals/app/views/additionals/settings/_web_apis.html.slim @@ -1,7 +1,5 @@ -br -h3 = l(:label_web_apis) p - = content_tag(:label, l(:label_google_maps_embed_api)) - = text_field_tag('settings[google_maps_api_key]', @settings[:google_maps_api_key], size: 60) + = additionals_settings_textfield :google_maps_api_key, size: 60 em.info = t(:google_maps_embed_api_html) -= call_hook(:additionals_settings_web_apis, settings: @settings) + += call_hook :additionals_settings_web_apis, settings: @settings diff --git a/plugins/additionals/app/views/additionals/settings/_wiki.html.slim b/plugins/additionals/app/views/additionals/settings/_wiki.html.slim index bd0986a..f88d94b 100755 --- a/plugins/additionals/app/views/additionals/settings/_wiki.html.slim +++ b/plugins/additionals/app/views/additionals/settings/_wiki.html.slim @@ -1,34 +1,23 @@ -.info = t(:top_wiki_help) +em.info = t(:top_wiki_help) br -h3 = l(:label_content_plural) -p - = content_tag(:label, l(:label_global_wiki_sidebar)) - = text_area_tag 'settings[global_wiki_sidebar]', @settings[:global_wiki_sidebar], class: 'wiki-edit', rows: 10 - em.info - = l(:global_wiki_sidebar_info) -p - = content_tag(:label, l(:label_global_wiki_header)) - = text_area_tag 'settings[global_wiki_header]', @settings[:global_wiki_header], class: 'wiki-edit', rows: 5 - em.info - = l(:global_wiki_header_info) -p - = content_tag(:label, l(:label_global_wiki_footer)) - = text_area_tag 'settings[global_wiki_footer]', @settings[:global_wiki_footer], class: 'wiki-edit', rows: 5 - em.info - = l(:global_wiki_footer_info) +fieldset.settings + legend = l(:label_content_plural) -br -h3 = l(:label_pdf_wiki_settings) + p + = additionals_settings_textarea :global_wiki_sidebar + em.info + = l(:global_wiki_sidebar_info) -p - = content_tag(:label, l(:label_wiki_pdf_remove_title)) - = check_box_tag 'settings[wiki_pdf_remove_title]', 1, @settings[:wiki_pdf_remove_title].to_i == 1 - em.info - = l(:wiki_pdf_remove_title_info) -p - = content_tag(:label, l(:label_wiki_pdf_remove_attachments)) - = check_box_tag 'settings[wiki_pdf_remove_attachments]', 1, @settings[:wiki_pdf_remove_attachments].to_i == 1 - em.info - = l(:wiki_pdf_remove_attachments_info) +fieldset.settings + legend = l(:label_pdf_wiki_settings) + + p + = additionals_settings_checkbox :wiki_pdf_remove_title + em.info + = l(:wiki_pdf_remove_title_info) + p + = additionals_settings_checkbox :wiki_pdf_remove_attachments + em.info + = l(:wiki_pdf_remove_attachments_info) diff --git a/plugins/additionals/app/views/admin/_system_info.html.slim b/plugins/additionals/app/views/admin/_system_info.html.slim index 3c29e37..11022e9 100755 --- a/plugins/additionals/app/views/admin/_system_info.html.slim +++ b/plugins/additionals/app/views/admin/_system_info.html.slim @@ -1,11 +1,13 @@ table.list tr td.name - = "#{l(:label_system_info)}:" + = l :label_system_info + | : td.name = system_info tr td.name - = "#{l(:label_uptime)}:" + = l :label_uptime + | : td.name = system_uptime diff --git a/plugins/additionals/app/views/auto_completes/_issue_assignee.html.erb b/plugins/additionals/app/views/auto_completes/_issue_assignee.html.erb new file mode 100644 index 0000000..e455af3 --- /dev/null +++ b/plugins/additionals/app/views/auto_completes/_issue_assignee.html.erb @@ -0,0 +1,7 @@ +<%= raw @assignee.map { |principal| { + 'id' => principal.id, + 'text' => principal.name, + 'value' => principal.id + } + }.to_json +%> diff --git a/plugins/additionals/app/views/common/_dashboard.html.slim b/plugins/additionals/app/views/common/_dashboard.html.slim new file mode 100644 index 0000000..e5889c2 --- /dev/null +++ b/plugins/additionals/app/views/common/_dashboard.html.slim @@ -0,0 +1,43 @@ += call_hook :view_dashboard_top, dashboard: dashboard, project: @project + +#my-page.splitcontent class="#{dashboard_css_classes(dashboard)}" + - dashboard.available_groups.each do |group| + .block-receiver id="list-#{group}" class="splitcontent#{group}" + = render_dashboard_blocks dashboard.layout[group], dashboard + += call_hook :view_dashboard_bottom, dashboard: dashboard, project: @project + += context_menu + +/ required for drap & drop work +/ (this should always set, because to support new entries) +- include_calendar_headers_tags + +- if dashboard.content.with_chartjs? + - content_for :header_tags do + = additionals_library_load %i[chartjs chartjs_colorschemes chartjs_datalabels] + +javascript: + $(function() { + $('#block-select').val(''); + $('.block-receiver').sortable({ + connectWith: '.block-receiver', + tolerance: 'pointer', + handle: '.sort-handle', + start: function(event, ui){$(this).parent().addClass('dragging');}, + stop: function(event, ui){$(this).parent().removeClass('dragging');}, + update: function(event, ui){ + // trigger the call on the list that receives the block only + if ($(this).find(ui.item).length > 0) { + $.ajax({ + url: "#{escape_javascript _order_blocks_dashboard_path(@project, dashboard)}", + type: 'post', + data: { + 'group': $(this).attr('id').replace(/^list-/, ''), + 'blocks': $.map($(this).children(), function(el){return $(el).attr('id').replace(/^block-/, '');}) + } + }); + } + } + }); + }); diff --git a/plugins/additionals/app/views/context_menus/_additionals_closed_issues.html.slim b/plugins/additionals/app/views/context_menus/_additionals_closed_issues.html.slim index e68a764..aec5400 100755 --- a/plugins/additionals/app/views/context_menus/_additionals_closed_issues.html.slim +++ b/plugins/additionals/app/views/context_menus/_additionals_closed_issues.html.slim @@ -1,11 +1,13 @@ -- if Additionals.setting?(:issue_freezed_with_close) && !User.current.allowed_to?(:edit_closed_issues, project) - - if @issues.detect(&:closed?) - ruby: - @safe_attributes = [] - @can[:edit] = false - @can[:edit] = false - @allowed_statuses = nil - @trackers = nil - @can[:add_watchers] = nil - @can[:delete] = nil - @options_by_custom_field = [] +ruby: + if Additionals.setting?(:issue_freezed_with_close) && + !User.current.allowed_to?(:edit_closed_issues, project) && + @issues.detect(&:closed?) + @safe_attributes = [] + @can[:edit] = false + @can[:edit] = false + @allowed_statuses = nil + @trackers = nil + @can[:add_watchers] = nil + @can[:delete] = nil + @options_by_custom_field = [] + end diff --git a/plugins/additionals/app/views/custom_fields/formats/_additionals_text.html.slim b/plugins/additionals/app/views/custom_fields/formats/_additionals_text.html.slim new file mode 100644 index 0000000..cb68917 --- /dev/null +++ b/plugins/additionals/app/views/custom_fields/formats/_additionals_text.html.slim @@ -0,0 +1,3 @@ +- if custom_fields_with_full_with_layout.include? @custom_field.class.name + p + = f.check_box :full_width_layout diff --git a/plugins/additionals/app/views/dashboard_async_blocks/_update_order_by.js.erb b/plugins/additionals/app/views/dashboard_async_blocks/_update_order_by.js.erb new file mode 100644 index 0000000..e730b08 --- /dev/null +++ b/plugins/additionals/app/views/dashboard_async_blocks/_update_order_by.js.erb @@ -0,0 +1 @@ +$("#block-<%= block %>").replaceWith("<%= escape_javascript render_dashboard_block(block.to_s, dashboard, sort_options) %>"); diff --git a/plugins/additionals/app/views/dashboards/_form.html.slim b/plugins/additionals/app/views/dashboards/_form.html.slim new file mode 100644 index 0000000..2ac2c01 --- /dev/null +++ b/plugins/additionals/app/views/dashboards/_form.html.slim @@ -0,0 +1,90 @@ += error_messages_for 'dashboard' + +.box.tabular.attributes + p + = f.text_field :name, size: 255, required: true + + p + = f.text_area :description, rows: addtionals_textarea_cols(@dashboard.description, min: 4), class: 'wiki-edit' + + .splitcontent + .splitcontentleft + + - if @dashboard.new_record? + = hidden_field_tag 'dashboard[dashboard_type]', @dashboard.dashboard_type + + - if @project && @allowed_projects.present? && @allowed_projects.count > 1 + p + = f.select :project_id, + project_tree_options_for_select(@allowed_projects, + selected: @dashboard.project, + include_blank: true), + {}, + disabled: !@dashboard.project_id_can_change? + em.info + = l(:info_dashboard_project_select) + - else + = hidden_field_tag 'dashboard[project_id]', @project&.id + + - if User.current.allowed_to?(:share_dashboards, @project, global: true) || \ + User.current.allowed_to?(:set_system_dashboards, @project, global: true) + + p + label = l(:field_visible) + label.block + = radio_button 'dashboard', 'visibility', Dashboard::VISIBILITY_PRIVATE + ' + = l(:label_visibility_private) + label.block + = radio_button 'dashboard', 'visibility', Dashboard::VISIBILITY_PUBLIC + ' + = l(:label_visibility_public) + label.block + = radio_button 'dashboard', 'visibility', Dashboard::VISIBILITY_ROLES + ' + = l(:label_visibility_roles) + ' : + - Role.givable.sorted.each do |role| + label.block.role-visibility + = check_box_tag 'dashboard[role_ids][]', role.id, @dashboard.role_ids.include?(role.id), id: nil + ' + = role.name + = hidden_field_tag 'dashboard[role_ids][]', '' + + .splitcontentright + p + = f.check_box :enable_sidebar + + - if User.current.allowed_to? :set_system_dashboards, @project, global: true + p = f.check_box :system_default, disabled: !@dashboard.destroyable? + p#always-expose = f.check_box :always_expose + - elsif @dashboard.system_default? + p = f.check_box :system_default, disabled: true + p = f.check_box :always_expose + + - if @dashboard.persisted? + p.object-select + = f.select :author_id, + author_options_for_select(@project, @dashboard, :save_dashboards), + required: true + + = call_hook :view_dashboard_form_details_bottom, dashboard: @dashboard, form: f + +javascript: + $(function() { + $("input[name='dashboard[visibility]']").change(function(){ + var roles_checked = $('#dashboard_visibility_1').is(':checked'); + var private_checked = $('#dashboard_visibility_0').is(':checked'); + $("input[name='dashboard[role_ids][]'][type=checkbox]").attr('disabled', !roles_checked); + }).trigger('change'); + + $("input[name='dashboard[system_default]']").change(function(){ + var selection = $('#dashboard_system_default').is(':checked'); + if (selection) { + $('#always-expose').show(); + } + else { + $('#always-expose').hide(); + } + }).trigger('change'); + }); diff --git a/plugins/additionals/app/views/dashboards/add_block.js.erb b/plugins/additionals/app/views/dashboards/add_block.js.erb new file mode 100644 index 0000000..d1da381 --- /dev/null +++ b/plugins/additionals/app/views/dashboards/add_block.js.erb @@ -0,0 +1,3 @@ +$("#block-<%= escape_javascript @block %>").remove(); +$("#list-top").prepend("<%= escape_javascript render_dashboard_blocks([@block], @dashboard) %>"); +$("#block-select").replaceWith("<%= escape_javascript dashboard_block_select_tag(@dashboard) %>"); diff --git a/plugins/additionals/app/views/dashboards/block_error.html.slim b/plugins/additionals/app/views/dashboards/block_error.html.slim new file mode 100644 index 0000000..b928af0 --- /dev/null +++ b/plugins/additionals/app/views/dashboards/block_error.html.slim @@ -0,0 +1,4 @@ +p#errorExplanation + ' An error occurred while executing dashboard block + = tag.i @block + ' and has been logged. Please report this error to your Redmine administrator. diff --git a/plugins/additionals/app/views/dashboards/blocks/_activity.html.slim b/plugins/additionals/app/views/dashboards/blocks/_activity.html.slim new file mode 100644 index 0000000..4d0f70f --- /dev/null +++ b/plugins/additionals/app/views/dashboards/blocks/_activity.html.slim @@ -0,0 +1,11 @@ +- cache render_async_cache_key(_dashboard_async_blocks_path(@project, + dashboard.async_params(block, async, settings))), + expires_in: async[:cache_expires_in] || DashboardContent::RENDER_ASYNC_CACHE_EXPIRES_IN, + skip_digest: true do + + - events_by_day = activity_dashboard_data settings, dashboard + - title = Additionals.true?(settings[:me_only]) ? l(:label_my_activity) : l(:label_activity) + h3 = link_to title, activity_path(user_id: User.current, + from: events_by_day.keys.first) + + = render partial: 'activities/activities', locals: { events_by_day: events_by_day } diff --git a/plugins/additionals/app/views/dashboards/blocks/_activity_settings.html.slim b/plugins/additionals/app/views/dashboards/blocks/_activity_settings.html.slim new file mode 100644 index 0000000..b22be45 --- /dev/null +++ b/plugins/additionals/app/views/dashboards/blocks/_activity_settings.html.slim @@ -0,0 +1,21 @@ +- max_entries = settings[:max_entries].presence || DashboardContent::DEFAULT_MAX_ENTRIES +div id="#{block}-settings" style="#{'display: none;' if hide}" + = form_tag(_update_layout_setting_dashboard_path(@project, @dashboard), remote: true) do + = hidden_field_tag "settings[#{block}][me_only]", '0' + .box + p + label + = l(:label_max_entries) + ' : + = number_field_tag "settings[#{block}][max_entries]", max_entries, min: 1, max: 1000, required: true + + p + label + = l(:label_only_my_activities) + ' : + = check_box_tag "settings[#{block}][me_only]", '1', Additionals.true?(settings[:me_only]) + + p + = submit_tag l(:button_save) + ' + = link_to_function l(:button_cancel), "$('##{block}-settings').toggle();" diff --git a/plugins/additionals/app/views/dashboards/blocks/_async.html.slim b/plugins/additionals/app/views/dashboards/blocks/_async.html.slim new file mode 100644 index 0000000..7e5a616 --- /dev/null +++ b/plugins/additionals/app/views/dashboards/blocks/_async.html.slim @@ -0,0 +1,31 @@ +- with_async = dashboard_async_required_settings? settings, async + +- unless with_async + h3 = settings[:title].presence || block_definition[:label] + +- if @can_edit && \ + block_definition[:no_settings].blank? && \ + (!block_definition.key?(:with_settings_if) || block_definition[:with_settings_if].call(@project)) + = render partial: block_definition[:settings_partial].presence || "#{async[:partial]}_settings", + locals: build_dashboard_partial_locals(block, + block_definition, + settings, + dashboard).merge({ hide: with_async }) + +- if with_async + = render_async_cache _dashboard_async_blocks_path(@project, + dashboard.async_params(block, async, settings)) do + .clear-both + p + i.fas.fa-sync.fa-spin + ' + = l(:label_loading) + + = content_for :render_async + + javascript: + $(function() { + $('#ajax-indicator').hide(); + }) +- else + p.nodata = l :label_no_data diff --git a/plugins/additionals/app/views/dashboards/blocks/_documents.html.slim b/plugins/additionals/app/views/dashboards/blocks/_documents.html.slim new file mode 100644 index 0000000..6f90179 --- /dev/null +++ b/plugins/additionals/app/views/dashboards/blocks/_documents.html.slim @@ -0,0 +1,27 @@ +h3.icon.icon-document = l :label_document_plural + +- if @can_edit + div id="#{block}-settings" style='display: none;' + = form_tag(_update_layout_setting_dashboard_path(@project, @dashboard), remote: true) do + .box + p + label + = l(:label_max_entries) + ' : + = number_field_tag "settings[#{block}][max_entries]", max_entries, min: 1, max: 1000, required: true + p + = submit_tag l(:button_save) + ' + = link_to_function l(:button_cancel), "$('##{block}-settings').toggle();" + +- if documents.any? + = render partial: 'documents/document', collection: documents + p + - if @project + = link_to l(:label_document_view_all), project_documents_path(@project) + /- else + / no route available + /= link_to l(:label_news_view_all), documents_path + +- else + p.nodata = l :label_no_data diff --git a/plugins/additionals/app/views/dashboards/blocks/_feed.html.slim b/plugins/additionals/app/views/dashboards/blocks/_feed.html.slim new file mode 100644 index 0000000..7494ecd --- /dev/null +++ b/plugins/additionals/app/views/dashboards/blocks/_feed.html.slim @@ -0,0 +1,23 @@ +- cache render_async_cache_key(_dashboard_async_blocks_path(@project, + dashboard.async_params(block, async, settings))), + expires_in: async[:cache_expires_in], + skip_digest: true do + + - feed = dashboard_feed_catcher settings[:url], settings[:max_entries] + h3 + = dashboard_feed_title settings[:title], block_definition + + - if feed[:valid] + - if feed[:items].count.positive? + ul.reporting-list.feed + - feed[:items].each do |item| + li + = link_to item[:title], + item[:link], + class: 'external', rel: 'noopener noreferrer', target: '_blank' + - else + p.nodata = l :label_no_data + - elsif settings[:url].blank? + p.nodata = l :label_no_data + - else + p.nodata = l(:label_invalid_feed_data) diff --git a/plugins/additionals/app/views/dashboards/blocks/_feed_settings.html.slim b/plugins/additionals/app/views/dashboards/blocks/_feed_settings.html.slim new file mode 100644 index 0000000..46af816 --- /dev/null +++ b/plugins/additionals/app/views/dashboards/blocks/_feed_settings.html.slim @@ -0,0 +1,25 @@ +- max_entries = settings[:max_entries].presence || DashboardContent::DEFAULT_MAX_ENTRIES + +div id="#{block}-settings" style="#{'display: none;' if hide}" + = form_tag(_update_layout_setting_dashboard_path(@project, @dashboard), remote: true) do + .box + p + label + = l :field_title + ' : + = text_field_tag "settings[#{block}][title]", dashboard_feed_title(settings[:title], block_definition) + p + label + = l :field_url + ' : + = url_field_tag "settings[#{block}][url]", settings[:url], required: true + p + label + = l(:label_max_entries) + ' : + = number_field_tag "settings[#{block}][max_entries]", max_entries, min: 1, max: 100, required: true + + p + = submit_tag l(:button_save) + ' + = link_to_function l(:button_cancel), "$('##{block}-settings').toggle();" diff --git a/plugins/additionals/app/views/dashboards/blocks/_my_spent_time.html.slim b/plugins/additionals/app/views/dashboards/blocks/_my_spent_time.html.slim new file mode 100644 index 0000000..5e2941b --- /dev/null +++ b/plugins/additionals/app/views/dashboards/blocks/_my_spent_time.html.slim @@ -0,0 +1,35 @@ +h3 = block_definition[:label] + +- if @can_edit + div id="#{block}-settings" style='display: none;' + = form_tag(_update_layout_setting_dashboard_path(@project, @dashboard), remote: true) do + .box + p + label + = l :button_show + ' : + = number_field_tag "settings[#{block}][days]", days, min: 1, max: 1000, required: true + ' + = l :label_day_plural + p + = submit_tag l(:button_save) + ' + = link_to_function l(:button_cancel), "$('#my_spent_time-settings').toggle();" + +ul.reporting-list + li.today + = l :label_today + ' : + = l_hours_short entries_today.sum(&:hours) + + li.days + = l :label_last_n_days, days + ' : + = l_hours_short entries_days.sum(&:hours) + += link_to l(:label_spent_time), _time_entries_path(@project, nil, user_id: 'me') +' += link_to l(:button_log_time), + _new_time_entry_path(@project, nil), + class: 'icon-only icon-add', + title: l(:button_log_time) diff --git a/plugins/additionals/app/views/dashboards/blocks/_news.html.slim b/plugins/additionals/app/views/dashboards/blocks/_news.html.slim new file mode 100644 index 0000000..aa7238d --- /dev/null +++ b/plugins/additionals/app/views/dashboards/blocks/_news.html.slim @@ -0,0 +1,25 @@ +h3.icon.icon-news = l :label_news_latest + +- if @can_edit + div id="#{block}-settings" style='display: none;' + = form_tag(_update_layout_setting_dashboard_path(@project, @dashboard), remote: true) do + .box + p + label + = l(:label_max_entries) + ' : + = number_field_tag "settings[#{block}][max_entries]", max_entries, min: 1, max: 1000, required: true + p + = submit_tag l(:button_save) + ' + = link_to_function l(:button_cancel), "$('##{block}-settings').toggle();" + +- if news.any? + = render partial: 'news/news', collection: news + p + - if @project + = link_to l(:label_news_view_all), project_news_index_path(@project) + - else + = link_to l(:label_news_view_all), news_index_path +- else + p.nodata = l :label_no_data diff --git a/plugins/additionals/app/views/dashboards/blocks/_project_information.html.slim b/plugins/additionals/app/views/dashboards/blocks/_project_information.html.slim new file mode 100644 index 0000000..a9144cf --- /dev/null +++ b/plugins/additionals/app/views/dashboards/blocks/_project_information.html.slim @@ -0,0 +1,21 @@ +h3 = block_definition[:label] + +- if @project.description.present? + .wiki.project-description + = textilizable @project.description +- if @project.homepage.present? || @project.visible_custom_field_values.any? { |o| o.value.present? } + ul.reporting-list + - if @project.homepage.present? + li + span.label + = l :field_homepage + ' : + = link_to_if uri_with_safe_scheme?(@project.homepage), @project.homepage, @project.homepage, class: 'external' + - render_custom_field_values(@project) do |custom_field, formatted| + li class="#{custom_field.css_classes}" + span.label + = custom_field.name + ' : + = formatted + += call_hook :view_projects_show_dashboard_info_block, project: @project, dashboard: dashboard diff --git a/plugins/additionals/app/views/dashboards/blocks/_project_issues.html.slim b/plugins/additionals/app/views/dashboards/blocks/_project_issues.html.slim new file mode 100644 index 0000000..3f9b455 --- /dev/null +++ b/plugins/additionals/app/views/dashboards/blocks/_project_issues.html.slim @@ -0,0 +1,42 @@ +h3.icon.icon-issue + = l :label_issue_tracking + ' + = link_to l(:label_details), + project_issues_report_details_path(@project, detail: 'tracker'), + class: 'icon-only icon-zoom-in', + title: l(:label_details) + +- if @trackers.present? + table.list.issue-report + thead + tr + th + th + = l :label_open_issues_plural + th + = l :label_closed_issues_plural + th + = l :label_total + tbody + - @trackers.each do |tracker| + tr + td.name + = link_to tracker.name, project_issues_path(@project, set_filter: 1, tracker_id: tracker.id), title: tracker.description + td + = link_to @open_issues_by_tracker[tracker].to_i, project_issues_path(@project, set_filter: 1, tracker_id: tracker.id) + td + = link_to (@total_issues_by_tracker[tracker].to_i - @open_issues_by_tracker[tracker].to_i), + project_issues_path(@project, set_filter: 1, tracker_id: tracker.id, status_id: 'c') + td.total + = link_to @total_issues_by_tracker[tracker].to_i, + project_issues_path(@project, set_filter: 1, tracker_id: tracker.id, status_id: '*') +p + = link_to l(:label_issue_view_all), project_issues_path(@project, set_filter: 1) + ' | + = link_to l(:field_summary), project_issues_report_path(@project) + - if User.current.allowed_to? :view_calendar, @project, global: true + ' | + = link_to l(:label_calendar), project_calendar_path(@project) + - if User.current.allowed_to? :view_gantt, @project, global: true + ' | + = link_to l(:label_gantt), project_gantt_path(@project) diff --git a/plugins/additionals/app/views/dashboards/blocks/_project_subprojects.html.slim b/plugins/additionals/app/views/dashboards/blocks/_project_subprojects.html.slim new file mode 100644 index 0000000..bb8025a --- /dev/null +++ b/plugins/additionals/app/views/dashboards/blocks/_project_subprojects.html.slim @@ -0,0 +1,7 @@ +- if @subprojects.any? + h3.icon.icon-projects + = l(:label_subproject_plural) + ul.subprojects + - @subprojects.each do |project| + li + = link_to(project.name, project_path(project), class: project.css_classes).html_safe diff --git a/plugins/additionals/app/views/dashboards/blocks/_project_time_entries.html.slim b/plugins/additionals/app/views/dashboards/blocks/_project_time_entries.html.slim new file mode 100644 index 0000000..af361f5 --- /dev/null +++ b/plugins/additionals/app/views/dashboards/blocks/_project_time_entries.html.slim @@ -0,0 +1,20 @@ +h3.icon.icon-time + = block_definition[:label] +ul + - if @total_estimated_hours.present? + li + = l :field_estimated_hours + ' : + = l_hours @total_estimated_hours + - if @total_hours.present? + li + = l :label_spent_time + ' : + = l_hours @total_hours +p + - if User.current.allowed_to? :log_time, @project + = link_to l(:button_log_time), new_project_time_entry_path(@project) + ' | + = link_to l(:label_details), project_time_entries_path(@project) + ' | + = link_to l(:label_report), report_project_time_entries_path(@project) diff --git a/plugins/additionals/app/views/dashboards/blocks/_query_list.html.slim b/plugins/additionals/app/views/dashboards/blocks/_query_list.html.slim new file mode 100644 index 0000000..3372c23 --- /dev/null +++ b/plugins/additionals/app/views/dashboards/blocks/_query_list.html.slim @@ -0,0 +1,34 @@ +- cache render_async_cache_key(_dashboard_async_blocks_path(@project, dashboard.async_params(block, async, settings))), + expires_in: DashboardContent::RENDER_ASYNC_CACHE_EXPIRES_IN, + skip_digest: true do + + - query = klass.visible.find_by(id: settings[:query_id]) + - if query + ruby: + query.project = @project if query_block[:with_project] + count = query.send query_block[:count_method] + query.column_names = settings[:columns].split(',').map(&:to_sym) if settings[:columns].present? + query.sort_criteria = params[:sort] if params[:sort].present? + + h3.query-list-block + = dashboard_query_list_block_title query, query_block, @project + = " (#{count})" + = dashboard_query_list_block_alerts dashboard, query, block_definition + + - if query.respond_to?(:description) && query.description.present? + .query-description + = textilizable query, :description + + - if count.positive? + / required by some helpers of other plugins + - @query = query + = render partial: query_block[:list_partial], + locals: { query_block[:entities_var] => query.send(query_block[:entries_method], + limit: settings[:max_entries] || DashboardContent::DEFAULT_MAX_ENTRIES), + query: query, + query_options: { sort_param: 'sort', + sort_link_options: { method: :post, remote: true } } } + - else + p.nodata = l :label_no_data + - else + p.nodata = l :label_no_data diff --git a/plugins/additionals/app/views/dashboards/blocks/_query_list_settings.slim b/plugins/additionals/app/views/dashboards/blocks/_query_list_settings.slim new file mode 100644 index 0000000..12a82f3 --- /dev/null +++ b/plugins/additionals/app/views/dashboards/blocks/_query_list_settings.slim @@ -0,0 +1,27 @@ +- query = klass.find_by id: settings[:query_id] +- query.column_names = settings[:columns].map(&:to_sym) if query && settings[:columns].present? + +div id="#{block}-settings" style="#{'display: none;' if hide}" + = form_tag(_update_layout_setting_dashboard_path(@project, dashboard), remote: true) do + .box + - if query + = render_query_columns_selection query, name: "settings[#{block}][columns]" + - else + p + label + = block_definition[:label] + ' + = select_tag "settings[#{block}][query_id]", + options_for_query_select(klass, @project), + required: true + p + label + = l :label_max_entries + ' : + = number_field_tag "settings[#{block}][max_entries]", + settings[:max_entries].presence || DashboardContent::DEFAULT_MAX_ENTRIES, + min: 1, max: 100, required: true + p + = submit_tag l(:button_save) + ' + = link_to_function l(:button_cancel), "$('##{block}-settings').toggle();" diff --git a/plugins/additionals/app/views/dashboards/blocks/_text.html.slim b/plugins/additionals/app/views/dashboards/blocks/_text.html.slim new file mode 100644 index 0000000..22e4756 --- /dev/null +++ b/plugins/additionals/app/views/dashboards/blocks/_text.html.slim @@ -0,0 +1,26 @@ +ruby: + title = settings[:title] || l(:label_text) + text = settings[:text] + +- if title.present? + h3 = title + +- if @can_edit + div id="#{block}-settings" style='display: none;' + = form_tag(_update_layout_setting_dashboard_path(@project, @dashboard), remote: true) do + .box + p + label + = l :field_title + ' : + = text_field_tag "settings[#{block}][title]", title + p + = text_area_tag "settings[#{block}][text]", text, rows: addtionals_textarea_cols(text), class: 'wiki-edit' + = wikitoolbar_for "settings_#{block}_text" + p + = submit_tag l(:button_save) + ' + = link_to_function l(:button_cancel), "$('##{block}-settings').toggle();" + +.wiki + = textilizable text diff --git a/plugins/additionals/app/views/dashboards/blocks/_welcome.html.slim b/plugins/additionals/app/views/dashboards/blocks/_welcome.html.slim new file mode 100644 index 0000000..7573c86 --- /dev/null +++ b/plugins/additionals/app/views/dashboards/blocks/_welcome.html.slim @@ -0,0 +1,2 @@ +.wiki + = textilizable Setting.welcome_text diff --git a/plugins/additionals/app/views/dashboards/edit.html.slim b/plugins/additionals/app/views/dashboards/edit.html.slim new file mode 100644 index 0000000..5b6ecca --- /dev/null +++ b/plugins/additionals/app/views/dashboards/edit.html.slim @@ -0,0 +1,7 @@ +h2 = l(:button_dashboard_edit) += labelled_form_for :dashboard, + @dashboard, + url: { action: 'update', id: @dashboard.id }, + html: { multipart: true, method: :put, id: 'dashboard-form' } do |f| + = render partial: 'form', locals: { f: f } + = submit_tag l(:button_save) diff --git a/plugins/additionals/app/views/dashboards/index.api.rsb b/plugins/additionals/app/views/dashboards/index.api.rsb new file mode 100644 index 0000000..f0a5764 --- /dev/null +++ b/plugins/additionals/app/views/dashboards/index.api.rsb @@ -0,0 +1,20 @@ +api.array :dashboards, api_meta(total_count: @query_count, offset: @offset, limit: @limit) do + @dashboards.each do |dashboard| + api.dashboard do + api.id dashboard.id + api.name dashboard.name + api.dashboard_type dashboard.dashboard_type + api.description dashboard.description + api.enable_sidebar dashboard.enable_sidebar + api.system_default dashboard.system_default + api.always_expose dashboard.always_expose + api.project(id: dashboard.project_id, name: dashboard.project.name) unless dashboard.project.nil? + api.author id: dashboard.author_id, name: dashboard.author.name + api.visibility dashboard.visibility + api.created_on dashboard.created_at + api.updated_on dashboard.updated_at + + call_hook :api_dashboard_show, dashboard: dashboard + end + end +end diff --git a/plugins/additionals/app/views/dashboards/new.html.slim b/plugins/additionals/app/views/dashboards/new.html.slim new file mode 100644 index 0000000..00b5b98 --- /dev/null +++ b/plugins/additionals/app/views/dashboards/new.html.slim @@ -0,0 +1,7 @@ +h2 = l(:label_new_additional_dashboard) += labelled_form_for :dashboard, + @dashboard, + url: { action: 'create', project_id: @project }, + html: { multipart: true, id: 'dashboard-form' } do |f| + = render partial: 'form', locals: { f: f } + = submit_tag l(:button_save) diff --git a/plugins/additionals/app/views/dashboards/remove_block.js.erb b/plugins/additionals/app/views/dashboards/remove_block.js.erb new file mode 100644 index 0000000..cf01ceb --- /dev/null +++ b/plugins/additionals/app/views/dashboards/remove_block.js.erb @@ -0,0 +1,2 @@ +$("#block-<%= escape_javascript @block %>").remove(); +$("#block-select").replaceWith("<%= escape_javascript dashboard_block_select_tag(@dashboard) %>"); diff --git a/plugins/additionals/app/views/dashboards/show.api.rsb b/plugins/additionals/app/views/dashboards/show.api.rsb new file mode 100644 index 0000000..421304a --- /dev/null +++ b/plugins/additionals/app/views/dashboards/show.api.rsb @@ -0,0 +1,17 @@ +api.dashboard do + api.id @dashboard.id + api.name @dashboard.name + api.dashboard_type @dashboard.dashboard_type + api.description @dashboard.description + api.enable_sidebar @dashboard.enable_sidebar + api.system_default @dashboard.system_default + api.always_expose @dashboard.always_expose + api.project(id: @dashboard.project_id, name: @dashboard.project.name) unless @dashboard.project.nil? + api.author id: @dashboard.author_id, name: @dashboard.author.name + api.visibility @dashboard.visibility + api.options @dashboard.options + api.created_on @dashboard.created_at + api.updated_on @dashboard.updated_at + + call_hook :api_dashboard_show, dashboard: @dashboard +end diff --git a/plugins/additionals/app/views/dashboards/update_layout_setting.js.erb b/plugins/additionals/app/views/dashboards/update_layout_setting.js.erb new file mode 100644 index 0000000..c044a79 --- /dev/null +++ b/plugins/additionals/app/views/dashboards/update_layout_setting.js.erb @@ -0,0 +1,14 @@ +<% @updated_blocks.each do |block| %> + $("#block-<%= block %>").replaceWith("<%= escape_javascript render_dashboard_block(block.to_s, @dashboard) %>"); +<% end %> + + +$('[title]').tooltip({ + show: { + delay: 400 + }, + position: { + my: "center bottom-5", + at: "center top" + } +}); diff --git a/plugins/additionals/app/views/hooks/_view_contacts_form.html.slim b/plugins/additionals/app/views/hooks/_view_contacts_form.html.slim new file mode 100644 index 0000000..fea1e64 --- /dev/null +++ b/plugins/additionals/app/views/hooks/_view_contacts_form.html.slim @@ -0,0 +1 @@ += call_hook :view_contacts_form_details_bottom, contact: @contact, form: f diff --git a/plugins/additionals/app/views/hooks/_view_users_contextual.html.slim b/plugins/additionals/app/views/hooks/_view_users_contextual.html.slim deleted file mode 100755 index be2e2be..0000000 --- a/plugins/additionals/app/views/hooks/_view_users_contextual.html.slim +++ /dev/null @@ -1 +0,0 @@ -= call_hook(:view_users_show_contextual, user: @user) diff --git a/plugins/additionals/app/views/hooks/_view_users_show.html.slim b/plugins/additionals/app/views/hooks/_view_users_show.html.slim deleted file mode 100755 index 41e2ebe..0000000 --- a/plugins/additionals/app/views/hooks/_view_users_show.html.slim +++ /dev/null @@ -1 +0,0 @@ -= call_hook(:view_users_show_info, user: @user) diff --git a/plugins/additionals/app/views/hooks/_view_users_show_contextual.html.slim b/plugins/additionals/app/views/hooks/_view_users_show_contextual.html.slim new file mode 100644 index 0000000..e87391e --- /dev/null +++ b/plugins/additionals/app/views/hooks/_view_users_show_contextual.html.slim @@ -0,0 +1 @@ += call_hook :view_users_show_contextual, user: @user diff --git a/plugins/additionals/app/views/hooks/_view_users_show_info.html.slim b/plugins/additionals/app/views/hooks/_view_users_show_info.html.slim new file mode 100644 index 0000000..c1dcabb --- /dev/null +++ b/plugins/additionals/app/views/hooks/_view_users_show_info.html.slim @@ -0,0 +1 @@ += call_hook :view_users_show_info, user: @user diff --git a/plugins/additionals/app/views/issues/_additionals_action_menu.html.slim b/plugins/additionals/app/views/issues/_additionals_action_menu.html.slim index d28c675..7ec5a38 100755 --- a/plugins/additionals/app/views/issues/_additionals_action_menu.html.slim +++ b/plugins/additionals/app/views/issues/_additionals_action_menu.html.slim @@ -1,5 +1,5 @@ - if User.current.logged? && @issue.editable? && Additionals.setting?(:issue_assign_to_me) && \ @issue.assigned_to_id != User.current.id && @project.assignable_users.detect { |u| u.id == User.current.id } - = link_to(font_awesome_icon('far_user-circle', post_text: l(:button_assign_to_me)), + = link_to font_awesome_icon('far_user-circle', post_text: l(:button_assign_to_me)), issue_assign_to_me_path(@issue), method: :put, - class: 'assign-to-me') + class: 'assign-to-me' diff --git a/plugins/additionals/app/views/issues/_additionals_action_menu_log_time.html.slim b/plugins/additionals/app/views/issues/_additionals_action_menu_log_time.html.slim new file mode 100644 index 0000000..ebc5f11 --- /dev/null +++ b/plugins/additionals/app/views/issues/_additionals_action_menu_log_time.html.slim @@ -0,0 +1,2 @@ +- if User.current.allowed_to?(:log_time, @project) && @issue.log_time_allowed? + = link_to l(:button_log_time), new_issue_time_entry_path(@issue), class: 'icon icon-time-add' diff --git a/plugins/additionals/app/views/issues/_additionals_sidebar.html.slim b/plugins/additionals/app/views/issues/_additionals_sidebar_issues.html.slim old mode 100755 new mode 100644 similarity index 97% rename from plugins/additionals/app/views/issues/_additionals_sidebar.html.slim rename to plugins/additionals/app/views/issues/_additionals_sidebar_issues.html.slim index 092055e..cbd8e00 --- a/plugins/additionals/app/views/issues/_additionals_sidebar.html.slim +++ b/plugins/additionals/app/views/issues/_additionals_sidebar_issues.html.slim @@ -17,5 +17,3 @@ = link_to(font_awesome_icon('far_caret-square-left', post_text: s.name), issue_change_status_path(@issue, new_status_id: s.id), method: :put, class: "status-switch status-#{s.id}") - - h3 = l(:label_planning) diff --git a/plugins/additionals/app/views/issues/_additionals_sidebar_queries.html.slim b/plugins/additionals/app/views/issues/_additionals_sidebar_queries.html.slim new file mode 100644 index 0000000..08826a2 --- /dev/null +++ b/plugins/additionals/app/views/issues/_additionals_sidebar_queries.html.slim @@ -0,0 +1,4 @@ +- if Additionals.setting(:global_sidebar).present? + br + .sidebar-additionals + = textilizable(Additionals.setting(:global_sidebar)) diff --git a/plugins/additionals/app/views/issues/_change_author.html.slim b/plugins/additionals/app/views/issues/_change_author.html.slim index 8d6b343..d14e440 100755 --- a/plugins/additionals/app/views/issues/_change_author.html.slim +++ b/plugins/additionals/app/views/issues/_change_author.html.slim @@ -1,9 +1,9 @@ - if show_issue_change_author?(issue) && issue.safe_attribute?('author_id') - - author_options = issue_author_options_for_select(issue.project, issue) + - author_options = author_options_for_select(issue.project, issue) - if author_options.present? p#change_author = form.label_for_field :author_id - = link_to_function content_tag(:span, l(:button_edit), class: 'icon icon-edit'), '$(this).hide(); $("#issue_author_id").show()' + = link_to_function tag.span(l(:button_edit), class: 'icon icon-edit'), '$(this).hide(); $("#issue_author_id").show()' = form.select :author_id, author_options, { required: true, no_label: true }, style: 'display: none' javascript: $('#change_author').insertBefore($('#issue_tracker_id').parent()); diff --git a/plugins/additionals/app/views/issues/_change_author_bulk.html.slim b/plugins/additionals/app/views/issues/_change_author_bulk.html.slim index 9b68c63..786aa2b 100755 --- a/plugins/additionals/app/views/issues/_change_author_bulk.html.slim +++ b/plugins/additionals/app/views/issues/_change_author_bulk.html.slim @@ -1,7 +1,7 @@ - if @project && User.current.allowed_to?(:edit_issue_author, @project) - - author_options = issue_author_options_for_select(@project) + - author_options = author_options_for_select(@project) - if author_options.present? p#change_author = label_tag('issue[author_id]', l(:field_author)) = select_tag('issue[author_id]', - content_tag('option', l(:label_no_change_option), value: '') + author_options) + tag.option(l(:label_no_change_option), value: '') + author_options) diff --git a/plugins/additionals/app/views/issues/_new_ticket_message.html.slim b/plugins/additionals/app/views/issues/_new_ticket_message.html.slim index a5f7f8e..46dda79 100755 --- a/plugins/additionals/app/views/issues/_new_ticket_message.html.slim +++ b/plugins/additionals/app/views/issues/_new_ticket_message.html.slim @@ -1,3 +1,3 @@ - if @issue.new_ticket_message.present? .nodata.nodata-left - = textilizable(@issue.new_ticket_message).html_safe + = textilizable @issue, :new_ticket_message, inline_attachments: false diff --git a/plugins/additionals/app/views/projects/_additionals_sidebar.html.slim b/plugins/additionals/app/views/projects/_additionals_sidebar.html.slim new file mode 100644 index 0000000..57e4a4c --- /dev/null +++ b/plugins/additionals/app/views/projects/_additionals_sidebar.html.slim @@ -0,0 +1,4 @@ +- if Additionals.setting(:global_sidebar).present? + br + .sidebar-additionals + = textilizable Additionals.setting(:global_sidebar) diff --git a/plugins/additionals/app/views/projects/_project_overview.html.slim b/plugins/additionals/app/views/projects/_project_overview.html.slim deleted file mode 100755 index 1821bbc..0000000 --- a/plugins/additionals/app/views/projects/_project_overview.html.slim +++ /dev/null @@ -1,4 +0,0 @@ -- project_overview_content = Additionals.settings[:project_overview_content] -- if project_overview_content.present? - .project-content.wiki.box - = textilizable(project_overview_content) diff --git a/plugins/additionals/app/views/projects/show.html.slim b/plugins/additionals/app/views/projects/show.html.slim new file mode 100644 index 0000000..35005f6 --- /dev/null +++ b/plugins/additionals/app/views/projects/show.html.slim @@ -0,0 +1,97 @@ +/ Some plugins use @news (e.g. redmine_wysiwyg_editor), to detect news +- @news = nil +.contextual + - if User.current.allowed_to?(:save_dashboards, @project) && @dashboard&.editable? + = link_to l(:button_dashboard_edit), + edit_project_dashboard_path(@project, @dashboard), + class: 'icon icon-edit' + + - unless Redmine::Plugin.installed? 'redmine_reporting' + = bookmark_link @project + = call_hook :view_project_contextual_links, project: @project + + - if @dashboard&.editable? + = form_tag(_add_block_dashboard_path(@project, @dashboard), remote: true, id: 'block-form', authenticity_token: true) do + = dashboard_block_select_tag @dashboard + + = actions_dropdown do + - if User.current.allowed_to? :add_subprojects, @project + = link_to l(:label_subproject_new), new_project_path(parent_id: @project), class: 'icon icon-add' + - if User.current.allowed_to?(:close_project, @project) + - if @project.active? + = link_to l(:button_close), + close_project_path(@project), + data: { confirm: l(:text_are_you_sure) }, method: :post, class: 'icon icon-lock' + - else + = link_to l(:button_reopen), + reopen_project_path(@project), + data: { confirm: l(:text_are_you_sure) }, method: :post, class: 'icon icon-unlock' + + - if User.current.admin? + - if @project.archived? + = link_to l(:button_unarchive), + unarchive_project_path(@project, status: params[:status]), + method: :post, class: 'icon icon-unlock' + - else + = link_to l(:button_archive), + archive_project_path(@project, status: params[:status]), + data: { confirm: l(:text_are_you_sure) }, method: :post, class: 'icon icon-lock' + = link_to l(:button_copy), copy_project_path(@project), class: 'icon icon-copy' + = link_to l(:button_delete), project_path(@project), method: :delete, class: 'icon icon-del' + + - if User.current.allowed_to? :save_dashboards, @project + = link_to l(:label_new_dashboard), + new_project_dashboard_path(@project), + class: 'icon icon-add new-additionals-dashboard' + + - if @dashboard&.destroyable? + = delete_dashboard_link project_dashboard_path(@project, @dashboard), + class: 'icon icon-del' + + = sidebar_action_toggle @dashboard_sidebar, @dashboard, @project + - unless @dashboard_sidebar + = render_dashboard_actionlist @dashboard, @project + + = call_hook :view_project_actions_dropdown, project: @project + + - if User.current.allowed_to?(:edit_project, @project) + = link_to_if_authorized l(:label_settings), + { controller: 'projects', action: 'settings', id: @project }, + class: 'icon icon-settings' + +h2 = project_overview_name @project, @dashboard + +- unless @project.active? + p.warning + span.icon.icon-lock + = l(:text_project_closed) + += render partial: 'common/dashboard', locals: { dashboard: @dashboard } + +javascript: + $(function() { + $('#block-projectmembers div.members').removeClass('box'); + }); + += call_hook :view_projects_show_bottom, project: @project + +- if @dashboard_sidebar + - content_for :sidebar do + = call_hook :view_projects_show_sidebar_top, project: @project + = render_sidebar_dashboards @dashboard, @project + - if Additionals.setting(:global_sidebar).present? + br + .sidebar-additionals + = textilizable Additionals.setting(:global_sidebar) + + = call_hook :view_projects_show_sidebar_bottom, project: @project + +- content_for :header_tags do + = auto_discovery_link_tag :atom, + { controller: 'activities', + action: 'index', + id: @project, + format: 'atom', + key: User.current.rss_key } + +- html_title l(:label_overview) diff --git a/plugins/additionals/app/views/queries/_additionals_group_view.html.slim b/plugins/additionals/app/views/queries/_additionals_group_view.html.slim new file mode 100644 index 0000000..e35f927 --- /dev/null +++ b/plugins/additionals/app/views/queries/_additionals_group_view.html.slim @@ -0,0 +1,13 @@ +- reset_cycle +tr.group.open + td colspan="#{query.inline_columns.size + 2}" + span.expander.icon.icon-expended[onclick="toggleRowGroup(this);"] + ' + span.name = group_name + - if group_count.present? + ' + span.badge.badge-count.count = group_count + ' + span.totals = group_totals + = link_to_function("#{l :button_collapse_all}/#{l :button_expand_all}", + 'toggleAllRowGroups(this)', class: 'toggle-all') diff --git a/plugins/additionals/app/views/reports/_additionals_simple.html.slim b/plugins/additionals/app/views/reports/_additionals_simple.html.slim new file mode 100644 index 0000000..ae108b6 --- /dev/null +++ b/plugins/additionals/app/views/reports/_additionals_simple.html.slim @@ -0,0 +1,7 @@ +ruby: + case field_name + when 'assigned_to_id' + rows = Setting.issue_group_assignment? ? @project.visible_principals : @project.visible_users # rubocop:disable Lint/UselessAssignment + when 'author_id' + rows = @project.visible_users # rubocop:disable Lint/UselessAssignment + end diff --git a/plugins/additionals/app/views/welcome/_overview_bottom.html.slim b/plugins/additionals/app/views/welcome/_overview_bottom.html.slim deleted file mode 100755 index dbe0d26..0000000 --- a/plugins/additionals/app/views/welcome/_overview_bottom.html.slim +++ /dev/null @@ -1,5 +0,0 @@ -- overview_bottom = Additionals.settings[:overview_bottom] -- if overview_bottom.present? - .clear-both - .overview-bottom.wiki.box - = textilizable(overview_bottom) diff --git a/plugins/additionals/app/views/welcome/_overview_news.html.slim b/plugins/additionals/app/views/welcome/_overview_news.html.slim deleted file mode 100755 index d9a0c2e..0000000 --- a/plugins/additionals/app/views/welcome/_overview_news.html.slim +++ /dev/null @@ -1,5 +0,0 @@ -- unless Additionals.setting?(:remove_news) - .news.box - h3 = l(:label_news_latest) - = render partial: 'news/news', collection: @news - = link_to l(:label_news_view_all), news_index_path diff --git a/plugins/additionals/app/views/welcome/_overview_right.html.slim b/plugins/additionals/app/views/welcome/_overview_right.html.slim deleted file mode 100755 index 98afad9..0000000 --- a/plugins/additionals/app/views/welcome/_overview_right.html.slim +++ /dev/null @@ -1,4 +0,0 @@ -- overview_right = Additionals.settings[:overview_right] -- if overview_right.present? - .overview-right.wiki.box - = textilizable(overview_right) diff --git a/plugins/additionals/app/views/welcome/_overview_top.html.slim b/plugins/additionals/app/views/welcome/_overview_top.html.slim deleted file mode 100755 index 3a38f9f..0000000 --- a/plugins/additionals/app/views/welcome/_overview_top.html.slim +++ /dev/null @@ -1,4 +0,0 @@ -- overview_top = Additionals.settings[:overview_top] -- if overview_top.present? - .overview-top.wiki.box - = textilizable(overview_top) diff --git a/plugins/additionals/app/views/welcome/_sidebar.html.slim b/plugins/additionals/app/views/welcome/_sidebar.html.slim new file mode 100644 index 0000000..62f0a95 --- /dev/null +++ b/plugins/additionals/app/views/welcome/_sidebar.html.slim @@ -0,0 +1,6 @@ +- if Additionals.setting(:global_sidebar).present? + br + .sidebar-additionals + = textilizable Additionals.setting(:global_sidebar) + += render_sidebar_dashboards @dashboard diff --git a/plugins/additionals/app/views/welcome/index.html.slim b/plugins/additionals/app/views/welcome/index.html.slim new file mode 100644 index 0000000..534c6f6 --- /dev/null +++ b/plugins/additionals/app/views/welcome/index.html.slim @@ -0,0 +1,54 @@ +/ Some plugins use @news (e.g. redmine_wysiwyg_editor), to detect news +- @news = nil +.contextual + - if User.current.allowed_to?(:save_dashboards, nil, global: true) && @dashboard&.editable? + = link_to l(:button_dashboard_edit), + edit_dashboard_path(@dashboard), + class: 'icon icon-edit' + + = call_hook :view_welcome_contextual_links + + - if @dashboard&.editable? + = form_tag(add_block_dashboard_path(@dashboard), remote: true, id: 'block-form', authenticity_token: true) do + = dashboard_block_select_tag @dashboard + + = actions_dropdown do + - if User.current.allowed_to? :save_dashboards, nil, global: true + = link_to l(:label_new_dashboard), + new_dashboard_path, + class: 'icon icon-add new-additionals-dashboard' + - if @dashboard&.destroyable? + = delete_dashboard_link dashboard_path(@dashboard), + class: 'icon icon-del' + = sidebar_action_toggle @dashboard_sidebar, @dashboard + - unless @dashboard_sidebar + = render_dashboard_actionlist @dashboard + + = call_hook :view_welcome_show_actions_dropdown + +h2 = welcome_overview_name @dashboard + += call_hook :view_welcome_index_top + += render partial: 'common/dashboard', locals: { dashboard: @dashboard } + += call_hook :view_welcome_index_bottom + +- if @dashboard_sidebar + - content_for :sidebar do + = render partial: 'sidebar' + = call_hook :view_welcome_show_sidebar_bottom + +- content_for :header_tags do + = auto_discovery_link_tag :atom, + { controller: 'news', + action: 'index', + key: User.current.rss_key, + format: 'atom' }, + title: "#{Setting.app_title}: #{l :label_news_latest}" + = auto_discovery_link_tag :atom, + { controller: 'activities', + action: 'index', + key: User.current.rss_key, + format: 'atom' }, + title: "#{Setting.app_title}: #{l :label_activity}" diff --git a/plugins/additionals/app/views/wiki/_additionals_sidebar.html.slim b/plugins/additionals/app/views/wiki/_additionals_sidebar.html.slim new file mode 100644 index 0000000..b279b66 --- /dev/null +++ b/plugins/additionals/app/views/wiki/_additionals_sidebar.html.slim @@ -0,0 +1,4 @@ +- if Additionals.setting(:global_sidebar).present? + .sidebar-additionals + = textilizable Additionals.setting(:global_sidebar) + br diff --git a/plugins/additionals/app/views/wiki/_calendar_macros.html.slim b/plugins/additionals/app/views/wiki/_calendar_macros.html.slim deleted file mode 100755 index dab17e4..0000000 --- a/plugins/additionals/app/views/wiki/_calendar_macros.html.slim +++ /dev/null @@ -1,17 +0,0 @@ -.month-calendar id="month-calendar-#{id}" - javascript: - $("#month-calendar-#{id}").datepicker({ - language: "#{locale}", - calendarWeeks: #{options[:show_weeks]}, - todayHighlight: true, - multidate: true, - disableTouchKeyboard: true, - defaultViewDate: { - year: #{options[:year]}, - month: #{options[:month]}, - day: 1 - } - }); - - unless selected.empty? - javascript: - $('#month-calendar-#{id}').datepicker('setDates', #{selected}); diff --git a/plugins/additionals/app/views/wiki/_global_sidebar.html.slim b/plugins/additionals/app/views/wiki/_global_sidebar.html.slim deleted file mode 100755 index 88c7a54..0000000 --- a/plugins/additionals/app/views/wiki/_global_sidebar.html.slim +++ /dev/null @@ -1,5 +0,0 @@ -- sidebar = Additionals.settings[:global_sidebar] -- if sidebar.present? - .sidebar-additionals - = textilizable(sidebar) - br diff --git a/plugins/additionals/app/views/wiki/_project_macros.html.slim b/plugins/additionals/app/views/wiki/_project_macros.html.slim index 4163432..5996112 100755 --- a/plugins/additionals/app/views/wiki/_project_macros.html.slim +++ b/plugins/additionals/app/views/wiki/_project_macros.html.slim @@ -1,15 +1,20 @@ .additionals-projects.box - if list_title h3 = list_title - ul - - @projects.each do |project| - li.project class="#{cycle('odd', 'even')}" - span[style='font-weight: bold;'] - = link_to_project(project) - - if project.homepage? - ' : - = link_to(project.homepage, project.homepage, @html_options) - - if with_create_issue && User.current.allowed_to?(:add_issues, project) - = link_to('', - new_project_issue_path(project_id: project), - class: 'icon icon-add', title: l(:label_issue_new)) + + table.list.projects + - project_tree(@projects, init_level: false) do |project, level| + tr id="project-#{project.id}" class="#{project_list_css_classes project, level}" + td.name + span[style='font-weight: bold;'] + - if Redmine::Plugin.installed? 'redmine_reporting' + = project_name_with_icon project + - else + = link_to_project project + - if project.homepage? + ' : + = link_to(project.homepage, project.homepage, @html_options) + - if with_create_issue && User.current.allowed_to?(:add_issues, project) + = link_to '', + new_project_issue_path(project_id: project), + class: 'icon icon-add', title: l(:label_issue_new) diff --git a/plugins/additionals/app/views/wiki/_show_additionals.html.slim b/plugins/additionals/app/views/wiki/_show_additionals.html.slim deleted file mode 100755 index eadb004..0000000 --- a/plugins/additionals/app/views/wiki/_show_additionals.html.slim +++ /dev/null @@ -1,4 +0,0 @@ -- content_for :header_tags do - = stylesheet_link_tag 'bootstrap-datepicker3.standalone.min', plugin: 'additionals' - = javascript_include_tag('bootstrap-datepicker.min', plugin: 'additionals') - = bootstrap_datepicker_locale diff --git a/plugins/additionals/app/views/wiki/_user_macros.html.slim b/plugins/additionals/app/views/wiki/_user_macros.html.slim index 38db12d..823d815 100755 --- a/plugins/additionals/app/views/wiki/_user_macros.html.slim +++ b/plugins/additionals/app/views/wiki/_user_macros.html.slim @@ -2,22 +2,22 @@ - if list_title h3 = list_title - users.each do |user| - .user.box class="#{cycle('odd', 'even')}" + .user.box class="#{cycle 'odd', 'even'}" div[style="float: left; display: block; margin-right: 5px;"] = avatar(user, size: 50) .user.line[style="font-weight: bold;"] - = link_to_user(user) + = link_to_user user - if !user_roles.nil? && user_roles[user.id] .user.line - = l(:field_role) + = l :field_role ' : = user_roles[user.id].join(', ').html_safe .user.line - = l(:field_login) + = l :field_login ' : - = link_to user.login, '/users/' + user.id.to_s + = link_to user.login, "/users/#{user.id}" - unless user.pref.hide_mail .user.line - = l(:field_mail) + = l :field_mail ' : - = mail_to(user.mail, nil, encode: 'javascript') + = mail_to user.mail, nil, encode: 'javascript' diff --git a/plugins/additionals/assets/images/ZeroClipboard.swf b/plugins/additionals/assets/images/ZeroClipboard.swf deleted file mode 100755 index aac7e4c..0000000 Binary files a/plugins/additionals/assets/images/ZeroClipboard.swf and /dev/null differ diff --git a/plugins/additionals/assets/images/button_selected.svg b/plugins/additionals/assets/images/button_selected.svg new file mode 100644 index 0000000..3aeba71 --- /dev/null +++ b/plugins/additionals/assets/images/button_selected.svg @@ -0,0 +1,11 @@ + + + diff --git a/plugins/additionals/assets/javascripts/.eslintignore b/plugins/additionals/assets/javascripts/.eslintignore index d304d63..b472d8f 100755 --- a/plugins/additionals/assets/javascripts/.eslintignore +++ b/plugins/additionals/assets/javascripts/.eslintignore @@ -1,5 +1,4 @@ # eslint ignore file *.js !additionals*.js -!noreferrer.js -!tooltips.js +!select2_helpers.js diff --git a/plugins/additionals/assets/javascripts/Chart.bundle.min.js b/plugins/additionals/assets/javascripts/Chart.bundle.min.js new file mode 100644 index 0000000..55d9eb0 --- /dev/null +++ b/plugins/additionals/assets/javascripts/Chart.bundle.min.js @@ -0,0 +1,7 @@ +/*! + * Chart.js v2.9.3 + * https://www.chartjs.org + * (c) 2019 Chart.js Contributors + * Released under the MIT License + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t=t||self).Chart=e()}(this,(function(){"use strict";"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self&&self;function t(){throw new Error("Dynamic requires are not currently supported by rollup-plugin-commonjs")}function e(t,e){return t(e={exports:{}},e.exports),e.exports}var n={aliceblue:[240,248,255],antiquewhite:[250,235,215],aqua:[0,255,255],aquamarine:[127,255,212],azure:[240,255,255],beige:[245,245,220],bisque:[255,228,196],black:[0,0,0],blanchedalmond:[255,235,205],blue:[0,0,255],blueviolet:[138,43,226],brown:[165,42,42],burlywood:[222,184,135],cadetblue:[95,158,160],chartreuse:[127,255,0],chocolate:[210,105,30],coral:[255,127,80],cornflowerblue:[100,149,237],cornsilk:[255,248,220],crimson:[220,20,60],cyan:[0,255,255],darkblue:[0,0,139],darkcyan:[0,139,139],darkgoldenrod:[184,134,11],darkgray:[169,169,169],darkgreen:[0,100,0],darkgrey:[169,169,169],darkkhaki:[189,183,107],darkmagenta:[139,0,139],darkolivegreen:[85,107,47],darkorange:[255,140,0],darkorchid:[153,50,204],darkred:[139,0,0],darksalmon:[233,150,122],darkseagreen:[143,188,143],darkslateblue:[72,61,139],darkslategray:[47,79,79],darkslategrey:[47,79,79],darkturquoise:[0,206,209],darkviolet:[148,0,211],deeppink:[255,20,147],deepskyblue:[0,191,255],dimgray:[105,105,105],dimgrey:[105,105,105],dodgerblue:[30,144,255],firebrick:[178,34,34],floralwhite:[255,250,240],forestgreen:[34,139,34],fuchsia:[255,0,255],gainsboro:[220,220,220],ghostwhite:[248,248,255],gold:[255,215,0],goldenrod:[218,165,32],gray:[128,128,128],green:[0,128,0],greenyellow:[173,255,47],grey:[128,128,128],honeydew:[240,255,240],hotpink:[255,105,180],indianred:[205,92,92],indigo:[75,0,130],ivory:[255,255,240],khaki:[240,230,140],lavender:[230,230,250],lavenderblush:[255,240,245],lawngreen:[124,252,0],lemonchiffon:[255,250,205],lightblue:[173,216,230],lightcoral:[240,128,128],lightcyan:[224,255,255],lightgoldenrodyellow:[250,250,210],lightgray:[211,211,211],lightgreen:[144,238,144],lightgrey:[211,211,211],lightpink:[255,182,193],lightsalmon:[255,160,122],lightseagreen:[32,178,170],lightskyblue:[135,206,250],lightslategray:[119,136,153],lightslategrey:[119,136,153],lightsteelblue:[176,196,222],lightyellow:[255,255,224],lime:[0,255,0],limegreen:[50,205,50],linen:[250,240,230],magenta:[255,0,255],maroon:[128,0,0],mediumaquamarine:[102,205,170],mediumblue:[0,0,205],mediumorchid:[186,85,211],mediumpurple:[147,112,219],mediumseagreen:[60,179,113],mediumslateblue:[123,104,238],mediumspringgreen:[0,250,154],mediumturquoise:[72,209,204],mediumvioletred:[199,21,133],midnightblue:[25,25,112],mintcream:[245,255,250],mistyrose:[255,228,225],moccasin:[255,228,181],navajowhite:[255,222,173],navy:[0,0,128],oldlace:[253,245,230],olive:[128,128,0],olivedrab:[107,142,35],orange:[255,165,0],orangered:[255,69,0],orchid:[218,112,214],palegoldenrod:[238,232,170],palegreen:[152,251,152],paleturquoise:[175,238,238],palevioletred:[219,112,147],papayawhip:[255,239,213],peachpuff:[255,218,185],peru:[205,133,63],pink:[255,192,203],plum:[221,160,221],powderblue:[176,224,230],purple:[128,0,128],rebeccapurple:[102,51,153],red:[255,0,0],rosybrown:[188,143,143],royalblue:[65,105,225],saddlebrown:[139,69,19],salmon:[250,128,114],sandybrown:[244,164,96],seagreen:[46,139,87],seashell:[255,245,238],sienna:[160,82,45],silver:[192,192,192],skyblue:[135,206,235],slateblue:[106,90,205],slategray:[112,128,144],slategrey:[112,128,144],snow:[255,250,250],springgreen:[0,255,127],steelblue:[70,130,180],tan:[210,180,140],teal:[0,128,128],thistle:[216,191,216],tomato:[255,99,71],turquoise:[64,224,208],violet:[238,130,238],wheat:[245,222,179],white:[255,255,255],whitesmoke:[245,245,245],yellow:[255,255,0],yellowgreen:[154,205,50]},i=e((function(t){var e={};for(var i in n)n.hasOwnProperty(i)&&(e[n[i]]=i);var a=t.exports={rgb:{channels:3,labels:"rgb"},hsl:{channels:3,labels:"hsl"},hsv:{channels:3,labels:"hsv"},hwb:{channels:3,labels:"hwb"},cmyk:{channels:4,labels:"cmyk"},xyz:{channels:3,labels:"xyz"},lab:{channels:3,labels:"lab"},lch:{channels:3,labels:"lch"},hex:{channels:1,labels:["hex"]},keyword:{channels:1,labels:["keyword"]},ansi16:{channels:1,labels:["ansi16"]},ansi256:{channels:1,labels:["ansi256"]},hcg:{channels:3,labels:["h","c","g"]},apple:{channels:3,labels:["r16","g16","b16"]},gray:{channels:1,labels:["gray"]}};for(var r in a)if(a.hasOwnProperty(r)){if(!("channels"in a[r]))throw new Error("missing channels property: "+r);if(!("labels"in a[r]))throw new Error("missing channel labels property: "+r);if(a[r].labels.length!==a[r].channels)throw new Error("channel and label counts mismatch: "+r);var o=a[r].channels,s=a[r].labels;delete a[r].channels,delete a[r].labels,Object.defineProperty(a[r],"channels",{value:o}),Object.defineProperty(a[r],"labels",{value:s})}a.rgb.hsl=function(t){var e,n,i=t[0]/255,a=t[1]/255,r=t[2]/255,o=Math.min(i,a,r),s=Math.max(i,a,r),l=s-o;return s===o?e=0:i===s?e=(a-r)/l:a===s?e=2+(r-i)/l:r===s&&(e=4+(i-a)/l),(e=Math.min(60*e,360))<0&&(e+=360),n=(o+s)/2,[e,100*(s===o?0:n<=.5?l/(s+o):l/(2-s-o)),100*n]},a.rgb.hsv=function(t){var e,n,i,a,r,o=t[0]/255,s=t[1]/255,l=t[2]/255,u=Math.max(o,s,l),d=u-Math.min(o,s,l),h=function(t){return(u-t)/6/d+.5};return 0===d?a=r=0:(r=d/u,e=h(o),n=h(s),i=h(l),o===u?a=i-n:s===u?a=1/3+e-i:l===u&&(a=2/3+n-e),a<0?a+=1:a>1&&(a-=1)),[360*a,100*r,100*u]},a.rgb.hwb=function(t){var e=t[0],n=t[1],i=t[2];return[a.rgb.hsl(t)[0],100*(1/255*Math.min(e,Math.min(n,i))),100*(i=1-1/255*Math.max(e,Math.max(n,i)))]},a.rgb.cmyk=function(t){var e,n=t[0]/255,i=t[1]/255,a=t[2]/255;return[100*((1-n-(e=Math.min(1-n,1-i,1-a)))/(1-e)||0),100*((1-i-e)/(1-e)||0),100*((1-a-e)/(1-e)||0),100*e]},a.rgb.keyword=function(t){var i=e[t];if(i)return i;var a,r,o,s=1/0;for(var l in n)if(n.hasOwnProperty(l)){var u=n[l],d=(r=t,o=u,Math.pow(r[0]-o[0],2)+Math.pow(r[1]-o[1],2)+Math.pow(r[2]-o[2],2));d=0&&!c||x<0&&c?o-p:o+p),{size:l,base:o,head:s,center:s+l/2}},calculateBarIndexPixels:function(t,e,n,i){var a="flex"===i.barThickness?function(t,e,n){var i,a=e.pixels,r=a[t],o=t>0?a[t-1]:null,s=t 0)for(u=-1;++u =0;)(r=i[o])&&(a&&4^r.compareDocumentPosition(a)&&a.parentNode.insertBefore(r,a),a=r);return this},sort:function(t){function n(n,e){return n&&e?t(n.__data__,e.__data__):!n-!e}t||(t=kt);for(var e=this._groups,r=e.length,i=new Array(r),o=0;o0&&n.stroke()},draw:function(){var t=this._chart.ctx,e=this._view;if(0!==e.opacity){var n={width:e.width,height:e.height},i={x:e.x,y:e.y},a=Math.abs(e.opacity<.001)?0:e.opacity,r=e.title.length||e.beforeBody.length||e.body.length||e.afterBody.length||e.footer.length;this._options.enabled&&r&&(t.save(),t.globalAlpha=a,this.drawBackground(i,e,t,n),i.y+=e.yPadding,H.rtl.overrideTextDirection(t,e.textDirection),this.drawTitle(i,e,t),this.drawBody(i,e,t),this.drawFooter(i,e,t),H.rtl.restoreTextDirection(t,e.textDirection),t.restore())}},handleEvent:function(t){var e,n=this,i=n._options;return n._lastActive=n._lastActive||[],"mouseout"===t.type?n._active=[]:(n._active=n._chart.getElementsAtEventForMode(t,i.mode,i),i.reverse&&n._active.reverse()),(e=!H.arrayEquals(n._active,n._lastActive))&&(n._lastActive=n._active,(i.enabled||i.custom)&&(n._eventPosition={x:t.x,y:t.y},n.update(!0),n.pivot())),e}}),Ue=Ye,Ge=je;Ge.positioners=Ue;var qe=H.valueOrDefault;function Ze(){return H.merge({},[].slice.call(arguments),{merger:function(t,e,n,i){if("xAxes"===t||"yAxes"===t){var a,r,o,s=n[t].length;for(e[t]||(e[t]=[]),a=0;a=e[t].length&&e[t].push({}),!e[t][a].type||o.type&&o.type!==e[t][a].type?H.merge(e[t][a],[Re.getScaleDefaults(r),o]):H.merge(e[t][a],o)}else H._merger(t,e,n,i)}})}function $e(){return H.merge({},[].slice.call(arguments),{merger:function(t,e,n,i){var a=e[t]||{},r=n[t];"scales"===t?e[t]=Ze(a,r):"scale"===t?e[t]=H.merge(a,[Re.getScaleDefaults(r.type),r]):H._merger(t,e,n,i)}})}function Xe(t){var e=t.options;H.each(t.scales,(function(e){me.removeBox(t,e)})),e=$e(W.global,W[t.config.type],e),t.options=t.config.options=e,t.ensureScalesHaveIDs(),t.buildOrUpdateScales(),t.tooltip._options=e.tooltips,t.tooltip.initialize()}function Ke(t,e,n){var i,a=function(t){return t.id===i};do{i=e+n++}while(H.findIndex(t,a)>=0);return i}function Je(t){return"top"===t||"bottom"===t}function Qe(t,e){return function(n,i){return n[t]===i[t]?n[e]-i[e]:n[t]-i[t]}}W._set("global",{elements:{},events:["mousemove","mouseout","click","touchstart","touchmove"],hover:{onHover:null,mode:"nearest",intersect:!0,animationDuration:400},onClick:null,maintainAspectRatio:!0,responsive:!0,responsiveAnimationDuration:0});var tn=function(t,e){return this.construct(t,e),this};H.extend(tn.prototype,{construct:function(t,e){var n=this;e=function(t){var e=(t=t||{}).data=t.data||{};return e.datasets=e.datasets||[],e.labels=e.labels||[],t.options=$e(W.global,W[t.type],t.options||{}),t}(e);var i=Ie.acquireContext(t,e),a=i&&i.canvas,r=a&&a.height,o=a&&a.width;n.id=H.uid(),n.ctx=i,n.canvas=a,n.config=e,n.width=o,n.height=r,n.aspectRatio=r?o/r:null,n.options=e.options,n._bufferedRender=!1,n._layers=[],n.chart=n,n.controller=n,tn.instances[n.id]=n,Object.defineProperty(n,"data",{get:function(){return n.config.data},set:function(t){n.config.data=t}}),i&&a?(n.initialize(),n.update()):console.error("Failed to create chart: can't acquire context from the given item")},initialize:function(){var t=this;return Le.notify(t,"beforeInit"),H.retinaScale(t,t.options.devicePixelRatio),t.bindEvents(),t.options.responsive&&t.resize(!0),t.initToolTip(),Le.notify(t,"afterInit"),t},clear:function(){return H.canvas.clear(this),this},stop:function(){return J.cancelAnimation(this),this},resize:function(t){var e=this,n=e.options,i=e.canvas,a=n.maintainAspectRatio&&e.aspectRatio||null,r=Math.max(0,Math.floor(H.getMaximumWidth(i))),o=Math.max(0,Math.floor(a?r/a:H.getMaximumHeight(i)));if((e.width!==r||e.height!==o)&&(i.width=e.width=r,i.height=e.height=o,i.style.width=r+"px",i.style.height=o+"px",H.retinaScale(e,n.devicePixelRatio),!t)){var s={width:r,height:o};Le.notify(e,"resize",[s]),n.onResize&&n.onResize(e,s),e.stop(),e.update({duration:n.responsiveAnimationDuration})}},ensureScalesHaveIDs:function(){var t=this.options,e=t.scales||{},n=t.scale;H.each(e.xAxes,(function(t,n){t.id||(t.id=Ke(e.xAxes,"x-axis-",n))})),H.each(e.yAxes,(function(t,n){t.id||(t.id=Ke(e.yAxes,"y-axis-",n))})),n&&(n.id=n.id||"scale")},buildOrUpdateScales:function(){var t=this,e=t.options,n=t.scales||{},i=[],a=Object.keys(n).reduce((function(t,e){return t[e]=!1,t}),{});e.scales&&(i=i.concat((e.scales.xAxes||[]).map((function(t){return{options:t,dtype:"category",dposition:"bottom"}})),(e.scales.yAxes||[]).map((function(t){return{options:t,dtype:"linear",dposition:"left"}})))),e.scale&&i.push({options:e.scale,dtype:"radialLinear",isDefault:!0,dposition:"chartArea"}),H.each(i,(function(e){var i=e.options,r=i.id,o=qe(i.type,e.dtype);Je(i.position)!==Je(e.dposition)&&(i.position=e.dposition),a[r]=!0;var s=null;if(r in n&&n[r].type===o)(s=n[r]).options=i,s.ctx=t.ctx,s.chart=t;else{var l=Re.getScaleConstructor(o);if(!l)return;s=new l({id:r,type:o,options:i,ctx:t.ctx,chart:t}),n[s.id]=s}s.mergeTicksOptions(),e.isDefault&&(t.scale=s)})),H.each(a,(function(t,e){t||delete n[e]})),t.scales=n,Re.addScalesToLayout(this)},buildOrUpdateControllers:function(){var t,e,n=this,i=[],a=n.data.datasets;for(t=0,e=a.length;t=0;t--)e=i.getDistanceFromCenterForValue(r.ticks.reverse?i.min:i.max),n=i.getPointPosition(t,e),a.beginPath(),a.moveTo(i.xCenter,i.yCenter),a.lineTo(n.x,n.y),a.stroke();a.restore()}},_drawLabels:function(){var t=this,e=t.ctx,n=t.options.ticks;if(n.display){var i,a,r=t.getIndexAngle(0),o=H.options._parseFont(n),s=Yn(n.fontColor,W.global.defaultFontColor);e.save(),e.font=o.string,e.translate(t.xCenter,t.yCenter),e.rotate(r),e.textAlign="center",e.textBaseline="middle",H.each(t.ticks,(function(r,l){(0!==l||n.reverse)&&(i=t.getDistanceFromCenterForValue(t.ticksAsNumbers[l]),n.showLabelBackdrop&&(a=e.measureText(r).width,e.fillStyle=n.backdropColor,e.fillRect(-a/2-n.backdropPaddingX,-i-o.size/2-n.backdropPaddingY,a+2*n.backdropPaddingX,o.size+2*n.backdropPaddingY)),e.fillStyle=s,e.fillText(r,0,-i))})),e.restore()}},_drawTitle:H.noop}),$n=Vn;Zn._defaults=$n;var Xn=H._deprecated,Kn=H.options.resolve,Jn=H.valueOrDefault,Qn=Number.MIN_SAFE_INTEGER||-9007199254740991,ti=Number.MAX_SAFE_INTEGER||9007199254740991,ei={millisecond:{common:!0,size:1,steps:1e3},second:{common:!0,size:1e3,steps:60},minute:{common:!0,size:6e4,steps:60},hour:{common:!0,size:36e5,steps:24},day:{common:!0,size:864e5,steps:30},week:{common:!1,size:6048e5,steps:4},month:{common:!0,size:2628e6,steps:12},quarter:{common:!1,size:7884e6,steps:4},year:{common:!0,size:3154e7}},ni=Object.keys(ei);function ii(t,e){return t-e}function ai(t){return H.valueOrDefault(t.time.min,t.ticks.min)}function ri(t){return H.valueOrDefault(t.time.max,t.ticks.max)}function oi(t,e,n,i){var a=function(t,e,n){for(var i,a,r,o=0,s=t.length-1;o>=0&&o<=s;){if(a=t[(i=o+s>>1)-1]||null,r=t[i],!a)return{lo:null,hi:r};if(r[e]=0;--n)(e=l[n].$filler)&&e.visible&&(a=(i=e.el)._view,r=i._children||[],o=e.mapper,s=a.backgroundColor||W.global.defaultColor,o&&s&&r.length&&(H.canvas.clipArea(u,t.chartArea),ki(u,r,o,a,s,i._loop),H.canvas.unclipArea(u)))}},Si=H.rtl.getRtlAdapter,Di=H.noop,Ci=H.valueOrDefault;function Pi(t,e){return t.usePointStyle&&t.boxWidth>e?e:t.boxWidth}W._set("global",{legend:{display:!0,position:"top",align:"center",fullWidth:!0,reverse:!1,weight:1e3,onClick:function(t,e){var n=e.datasetIndex,i=this.chart,a=i.getDatasetMeta(n);a.hidden=null===a.hidden?!i.data.datasets[n].hidden:null,i.update()},onHover:null,onLeave:null,labels:{boxWidth:40,padding:10,generateLabels:function(t){var e=t.data.datasets,n=t.options.legend||{},i=n.labels&&n.labels.usePointStyle;return t._getSortedDatasetMetas().map((function(n){var a=n.controller.getStyle(i?0:void 0);return{text:e[n.index].label,fillStyle:a.backgroundColor,hidden:!t.isDatasetVisible(n.index),lineCap:a.borderCapStyle,lineDash:a.borderDash,lineDashOffset:a.borderDashOffset,lineJoin:a.borderJoinStyle,lineWidth:a.borderWidth,strokeStyle:a.borderColor,pointStyle:a.pointStyle,rotation:a.rotation,datasetIndex:n.index}}),this)}}},legendCallback:function(t){var e,n,i,a=document.createElement("ul"),r=t.data.datasets;for(a.setAttribute("class",t.id+"-legend"),e=0,n=r.length;e";for(this.o.calendarWeeks&&(c+=' ",this.picker.find(".datepicker-days thead").append(c)}},fillMonths:function(){for(var a,b=this._utc_to_local(this.viewDate),c="",d=0;d<12;d++)a=b&&b.getMonth()===d?" focused":"",c+=''+q[this.o.language].monthsShort[d]+"";this.picker.find(".datepicker-months td").html(c)},setRange:function(b){b&&b.length?this.range=a.map(b,function(a){return a.valueOf()}):delete this.range,this.fill()},getClassNames:function(b){var c=[],f=this.viewDate.getUTCFullYear(),g=this.viewDate.getUTCMonth(),h=d();return b.getUTCFullYear() ');b'+A+" ")}v=this.getClassNames(p),v.push("day");var B=p.getUTCDate();this.o.beforeShowDay!==a.noop&&(e=this.o.beforeShowDay(this._utc_to_local(p)),e===b?e={}:"boolean"==typeof e?e={enabled:e}:"string"==typeof e&&(e={classes:e}),e.enabled===!1&&v.push("disabled"),e.classes&&(v=v.concat(e.classes.split(/\s+/))),e.tooltip&&(d=e.tooltip),e.content&&(B=e.content)),v=a.isFunction(a.uniqueSort)?a.uniqueSort(v):a.unique(v),w.push(''+B+" "),d=null,u===this.o.weekEnd&&w.push(""),p.setUTCDate(p.getUTCDate()+1)}this.picker.find(".datepicker-days tbody").html(w.join(""));var C=q[this.o.language].monthsTitle||q.en.monthsTitle||"Months",D=this.picker.find(".datepicker-months").find(".datepicker-switch").text(this.o.maxViewMode<2?C:g).end().find("tbody span").removeClass("active");if(a.each(this.dates,function(a,b){b.getUTCFullYear()===g&&D.eq(b.getUTCMonth()).addClass("active")}),(gk)&&D.addClass("disabled"),g===i&&D.slice(0,j).addClass("disabled"),g===k&&D.slice(l+1).addClass("disabled"),this.o.beforeShowMonth!==a.noop){var E=this;a.each(D,function(c,d){var e=new Date(g,c,1),f=E.o.beforeShowMonth(e);f===b?f={}:"boolean"==typeof f?f={enabled:f}:"string"==typeof f&&(f={classes:f}),f.enabled!==!1||a(d).hasClass("disabled")||a(d).addClass("disabled"),f.classes&&a(d).addClass(f.classes),f.tooltip&&a(d).prop("title",f.tooltip)})}this._fill_yearsView(".datepicker-years","year",10,g,i,k,this.o.beforeShowYear),this._fill_yearsView(".datepicker-decades","decade",100,g,i,k,this.o.beforeShowDecade),this._fill_yearsView(".datepicker-centuries","century",1e3,g,i,k,this.o.beforeShowCentury)}},updateNavArrows:function(){if(this._allow_update){var a,b,c=new Date(this.viewDate),d=c.getUTCFullYear(),e=c.getUTCMonth(),f=this.o.startDate!==-(1/0)?this.o.startDate.getUTCFullYear():-(1/0),g=this.o.startDate!==-(1/0)?this.o.startDate.getUTCMonth():-(1/0),h=this.o.endDate!==1/0?this.o.endDate.getUTCFullYear():1/0,i=this.o.endDate!==1/0?this.o.endDate.getUTCMonth():1/0,j=1;switch(this.viewMode){case 4:j*=10;case 3:j*=10;case 2:j*=10;case 1:a=Math.floor(d/j)*j ",
-contTemplate:''+o.templates.leftArrow+' '+o.templates.rightArrow+" ',footTemplate:' '};r.template=''+r.headTemplate+""+r.footTemplate+'
'+r.headTemplate+r.contTemplate+r.footTemplate+'
'+r.headTemplate+r.contTemplate+r.footTemplate+'
'+r.headTemplate+r.contTemplate+r.footTemplate+'
'+r.headTemplate+r.contTemplate+r.footTemplate+"
9999?"+"+Ga(t,6):Ga(t,4)}(t.getUTCFullYear())+"-"+Ga(t.getUTCMonth()+1,2)+"-"+Ga(t.getUTCDate(),2)+(i?"T"+Ga(n,2)+":"+Ga(e,2)+":"+Ga(r,2)+"."+Ga(i,3)+"Z":r?"T"+Ga(n,2)+":"+Ga(e,2)+":"+Ga(r,2)+"Z":e||n?"T"+Ga(n,2)+":"+Ga(e,2)+"Z":"")}function $a(t){var n=new RegExp('["'+t+"\n\r]"),e=t.charCodeAt(0);function r(t,n){var r,i=[],o=t.length,a=0,u=0,c=o<=0,f=!1;function s(){if(c)return ja;if(f)return f=!1,La;var n,r,i=a;if(34===t.charCodeAt(i)){for(;a++