From 509d7b244c70c5c9a64fe3861524406de5e29c75 Mon Sep 17 00:00:00 2001 From: briri Date: Tue, 15 Dec 2020 08:36:48 -0800 Subject: [PATCH 01/43] add ability to merge Orgs --- .../super_admin/orgs_controller.rb | 49 +++ app/helpers/super_admin/orgs/merge_helper.rb | 66 ++++ app/javascript/src/orgs/adminEdit.js | 3 + app/models/concerns/identifiable.rb | 2 +- app/models/guidance_group.rb | 31 +- app/models/org.rb | 86 ++++++ app/policies/org_policy.rb | 8 + .../super_admin/orgs/merge_presenter.rb | 166 ++++++++++ app/views/orgs/_merge_form.html.erb | 26 ++ app/views/orgs/admin_edit.html.erb | 18 ++ app/views/super_admin/orgs/_analysis.html.erb | 98 ++++++ .../super_admin/orgs/merge_analyze.js.erb | 9 + config/routes.rb | 8 +- .../20201208192403_drop_org_identifiers.rb | 5 + db/schema.rb | 15 +- .../super_admin/orgs_controller_spec.rb | 85 ++++++ spec/factories/trackers.rb | 2 +- spec/features/super_admins/merge_org_spec.rb | 77 +++++ spec/models/guidance_group_spec.rb | 40 +++ spec/models/org_spec.rb | 225 ++++++++++++++ .../super_admin/orgs/merge_presenter_spec.rb | 289 ++++++++++++++++++ 21 files changed, 1289 insertions(+), 19 deletions(-) create mode 100644 app/helpers/super_admin/orgs/merge_helper.rb create mode 100644 app/presenters/super_admin/orgs/merge_presenter.rb create mode 100644 app/views/orgs/_merge_form.html.erb create mode 100644 app/views/super_admin/orgs/_analysis.html.erb create mode 100644 app/views/super_admin/orgs/merge_analyze.js.erb create mode 100644 db/migrate/20201208192403_drop_org_identifiers.rb create mode 100644 spec/controllers/super_admin/orgs_controller_spec.rb create mode 100644 spec/features/super_admins/merge_org_spec.rb create mode 100644 spec/presenters/super_admin/orgs/merge_presenter_spec.rb diff --git a/app/controllers/super_admin/orgs_controller.rb b/app/controllers/super_admin/orgs_controller.rb index 477d7686e2..3614b91e8b 100644 --- a/app/controllers/super_admin/orgs_controller.rb +++ b/app/controllers/super_admin/orgs_controller.rb @@ -99,6 +99,51 @@ def destroy end end + # POST /super_admin/:id/merge_analyze + def merge_analyze + @org = Org.includes(:templates, :tracker, :annotations, + :departments, :token_permission_types, :funded_plans, + identifiers: [:identifier_scheme], + guidance_groups: [guidances: [:themes]], + users: [identifiers: [:identifier_scheme]]) + .find(params[:id]) + authorize @org + + lookup = OrgSelection::HashToOrgService.to_org( + hash: JSON.parse(merge_params[:id]), allow_create: false + ) + @target_org = Org.includes(:templates, :tracker, :annotations, + :departments, :token_permission_types, :funded_plans, + identifiers: [:identifier_scheme], + guidance_groups: [guidances: [:themes]], + users: [identifiers: [:identifier_scheme]]) + .find(lookup.id) + end + + # POST /super_admin/:id/merge_commit + def merge_commit + @org = Org.find(params[:id]) + authorize @org + + @target_org = Org.find_by(id: merge_params[:target_org]) + + if @target_org.present? + if @target_org.merge!(to_be_merged: @org) + msg = "Successfully merged '#{@org.name}' into '#{@target_org.name}'" + redirect_to super_admin_orgs_path, notice: msg + else + msg = _("An error occurred while trying to merge the Organisations.") + redirect_to admin_edit_org_path(@org), alert: msg + end + else + msg = _("Unable to merge the two Organisations at this time.") + redirect_to admin_edit_org_path(@org), alert: msg + end + rescue JSON::ParserError + msg = _("Unable to determine what records need to be merged.") + redirect_to admin_edit_org_path(@org), alert: msg + end + private def org_params @@ -110,6 +155,10 @@ def org_params :org_id, :org_name, :org_crosswalk) end + def merge_params + params.require(:org).permit(:org_name, :org_sources, :org_crosswalk, :id, :target_org) + end + end end diff --git a/app/helpers/super_admin/orgs/merge_helper.rb b/app/helpers/super_admin/orgs/merge_helper.rb new file mode 100644 index 0000000000..1c2051fdda --- /dev/null +++ b/app/helpers/super_admin/orgs/merge_helper.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module SuperAdmin + + module Orgs + + module MergeHelper + + def org_column_content(attributes:) + return "No mergeable attributes" unless attributes.present? && attributes.keys.any? + + html = "" + end + + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + def column_content(entries:, orcid:) + return _("None") unless entries.present? && entries.any? + + html = "" + end + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + + def merge_column_content(entries:, orcid:, to_org_name:) + return _("Nothing to merge") unless entries.present? && entries.any? + + html = _("

The following %{object_types} will be moved over to '%{org_name}':

") % { + object_types: entries.first.class.name.pluralize, + org_name: to_org_name + } + html + column_content(entries: entries, orcid: orcid) + end + + end + + end + +end diff --git a/app/javascript/src/orgs/adminEdit.js b/app/javascript/src/orgs/adminEdit.js index 49e90ed95c..c1b2240db0 100644 --- a/app/javascript/src/orgs/adminEdit.js +++ b/app/javascript/src/orgs/adminEdit.js @@ -56,4 +56,7 @@ $(() => { e.preventDefault(); $(e.target).parent('a').tooltip('toggle'); }); + + initAutocomplete('#org-merge-controls .autocomplete'); + scrubOrgSelectionParamsOnSubmit('form.edit_org') }); diff --git a/app/models/concerns/identifiable.rb b/app/models/concerns/identifiable.rb index 73f72d28a4..3593d8a061 100644 --- a/app/models/concerns/identifiable.rb +++ b/app/models/concerns/identifiable.rb @@ -58,7 +58,7 @@ def identifier_for_scheme(scheme:) # Combines the existing identifiers with the new ones def consolidate_identifiers!(array:) - return false unless array.present? && array.is_a?(Array) + return false unless array.present? && array.any? array.each do |id| next unless id.is_a?(Identifier) && id.value.present? diff --git a/app/models/guidance_group.rb b/app/models/guidance_group.rb index da753920dc..c4c2ba04c7 100644 --- a/app/models/guidance_group.rb +++ b/app/models/guidance_group.rb @@ -122,8 +122,7 @@ def self.all_viewable(user) all_viewable_groups = default_org_groups + funder_groups + organisation_groups - all_viewable_groups = all_viewable_groups.flatten.uniq - all_viewable_groups + all_viewable_groups.flatten.uniq end def self.create_org_default(org) @@ -134,4 +133,32 @@ def self.create_org_default(org) ) end + # ==================== + # = Instance methods = + # ==================== + + def merge!(to_be_merged:) + return self unless to_be_merged.is_a?(GuidanceGroup) + + GuidanceGroup.transaction do + # Reassociate any Plan-GuidanceGroup connections + to_be_merged.plans.each do |plan| + plan.guidance_groups << self + plan.save + end + # Reassociate the Guidances + to_be_merged.guidances.update_all(guidance_group_id: id) + to_be_merged.plans.delete_all + + # Terminate the transaction if the resulting Org is not valid + raise ActiveRecord::Rollback unless save + + # Reload and then drop the specified Org. The reload prevents ActiveRecord + # from also destroying associations that we've already reassociated above + raise ActiveRecord::Rollback unless to_be_merged.reload.destroy.present? + + reload + end + end + end diff --git a/app/models/org.rb b/app/models/org.rb index a591551dce..2e0d7ad008 100644 --- a/app/models/org.rb +++ b/app/models/org.rb @@ -299,6 +299,43 @@ def grant_api!(token_permission_type) token_permission_types.include? token_permission_type end + # Merges the specified Org into this Org + # rubocop:disable Metrics/AbcSize + def merge!(to_be_merged:) + return self unless to_be_merged.is_a?(Org) + + transaction do + merge_attributes!(to_be_merged: to_be_merged) + + # Merge all of the associated objects + to_be_merged.annotations.update_all(org_id: id) + merge_departments!(to_be_merged: to_be_merged) + to_be_merged.funded_plans.update_all(funder_id: id) + merge_guidance_groups!(to_be_merged: to_be_merged) + consolidate_identifiers!(array: to_be_merged.identifiers) + to_be_merged.plans.update_all(org_id: id) + to_be_merged.templates.update_all(org_id: id) + merge_token_permission_types!(to_be_merged: to_be_merged) + self.tracker = to_be_merged.tracker unless tracker.present? + to_be_merged.users.update_all(org_id: id) + + # Terminate the transaction if the resulting Org is not valid + raise ActiveRecord::Rollback unless save + + # Remove all of the remaining identifiers and token_permission_types + # that were not merged + to_be_merged.identifiers.delete_all + to_be_merged.token_permission_types.delete_all + + # Reload and then drop the specified Org. The reload prevents ActiveRecord + # from also destroying associations that we've already reassociated above + raise ActiveRecord::Rollback unless to_be_merged.reload.destroy.present? + + reload + end + end + # rubocop:enable Metrics/AbcSize + private ## @@ -310,4 +347,53 @@ def resize_image self.logo = logo.thumb("x100") # resize height and maintain aspect ratio end + # rubocop:disable Metrics/AbcSize + def merge_attributes!(to_be_merged:) + return false unless to_be_merged.is_a?(Org) + + self.target_url = to_be_merged.target_url unless target_url.present? + self.managed = true if !managed? && to_be_merged.managed? + self.links = to_be_merged.links unless links.nil? || links == "{\"org\":[]}" + self.logo_uid = to_be_merged.logo_uid unless logo.present? + self.logo_name = to_be_merged.logo_name unless logo.present? + self.contact_email = to_be_merged.contact_email unless contact_email.present? + self.contact_name = to_be_merged.contact_name unless contact_name.present? + self.feedback_enabled = to_be_merged.feedback_enabled unless feedback_enabled? + self.feedback_email_msg = to_be_merged.feedback_email_msg unless feedback_email_msg.present? + # rubocop:disable Layout/LineLength + self.feedback_email_subject = to_be_merged.feedback_email_subject unless feedback_email_subject.present? + # rubocop:enable Layout/LineLength + end + # rubocop:enable Metrics/AbcSize + + def merge_departments!(to_be_merged:) + return false unless to_be_merged.is_a?(Org) && to_be_merged.departments.any? + + existing = departments.collect { |dpt| dpt.name.downcase.strip } + # Do not attach departments if we already have an existing one + to_be_merged.departments.each do |department| + department.destroy if existing.include?(department.name.downcase) + department.update(org_id: id) unless existing.include?(department.name.downcase) + end + end + + def merge_guidance_groups!(to_be_merged:) + return false unless to_be_merged.is_a?(Org) && to_be_merged.guidance_groups.any? + + to_gg = guidance_groups.first + # Create a new GuidanceGroup if one does not already exist + to_gg = GuidanceGroup.create(org: self, name: abbreviation) unless to_gg.present? + + # Merge the GuidanceGroups + to_be_merged.guidance_groups.each { |from_gg| to_gg.merge!(to_be_merged: from_gg) } + end + + def merge_token_permission_types!(to_be_merged:) + return false unless to_be_merged.is_a?(Org) && to_be_merged.token_permission_types.any? + + to_be_merged.token_permission_types.each do |perm_type| + token_permission_types << perm_type unless token_permission_types.include?(perm_type) + end + end + end diff --git a/app/policies/org_policy.rb b/app/policies/org_policy.rb index f133cb2d10..bb7c678004 100644 --- a/app/policies/org_policy.rb +++ b/app/policies/org_policy.rb @@ -51,4 +51,12 @@ def templates? true end + def merge_analyze? + user.can_super_admin? + end + + def merge_commit? + user.can_super_admin? + end + end diff --git a/app/presenters/super_admin/orgs/merge_presenter.rb b/app/presenters/super_admin/orgs/merge_presenter.rb new file mode 100644 index 0000000000..5f6f8bb01b --- /dev/null +++ b/app/presenters/super_admin/orgs/merge_presenter.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +module SuperAdmin + + module Orgs + + class MergePresenter + + attr_accessor :from_org, :to_org, :from_org_name, :to_org_name, + :from_org_entries, :to_org_entries, :mergeable_entries, + :categories, :from_org_attributes, :to_org_attributes, + :mergeable_attributes + + def initialize(from_org:, to_org:) + @from_org = from_org + @to_org = to_org + + # Abbreviated Org names for display in tables + @from_org_name = @from_org.name.split(" ")[0..2].join(" ") + @to_org_name = @to_org.name.split(" ")[0..2].join(" ") + + # Association records + @from_org_entries = prepare_org(org: @from_org) + @to_org_entries = prepare_org(org: @to_org) + @mergeable_entries = prepare_mergeables + @categories = @from_org_entries.keys.sort { |a, b| a <=> b } + + # Specific Org columns + @from_org_attributes = org_attributes(org: @from_org) + @to_org_attributes = org_attributes(org: @to_org) + @mergeable_attributes = mergeable_columns + end + + private + + # rubocop:disable Metrics/AbcSize + def prepare_org(org:) + return {} unless org.present? && org.is_a?(Org) + + { + annotations: org.annotations.sort { |a, b| a.text <=> b.text }, + departments: org.departments.sort { |a, b| a.name <=> b.name }, + funded_plans: org.funded_plans.sort { |a, b| a.title <=> b.title }, + guidances: org.guidance_groups.collect(&:guidances).flatten, + identifiers: org.identifiers.sort { |a, b| a.value <=> b.value }, + # TODO: Org.plans is overridden and does not clearly identify Orgs that 'own' + # the plan (i.e. the one the user selected as the 'Research Org') + # Loading them directly here until issue #2724 is resolved + plans: Plan.where(org: org).sort { |a, b| a.title <=> b.title }, + templates: org.templates.sort { |a, b| a.title <=> b.title }, + token_permission_types: org.token_permission_types.sort { |a, b| a.to_s <=> b.to_s }, + tracker: [org.tracker].compact, + users: org.users.sort { |a, b| a.email <=> b.email } + } + end + # rubocop:enable Metrics/AbcSize + + def prepare_mergeables + return {} unless @from_org_entries.any? && @to_org_entries.any? + + { + annotations: diff_from_and_to(category: :annotations), + departments: diff_from_and_to(category: :departments), + funded_plans: diff_from_and_to(category: :funded_plans), + guidances: diff_from_and_to(category: :guidances), + identifiers: diff_from_and_to(category: :identifiers), + plans: diff_from_and_to(category: :plans), + templates: diff_from_and_to(category: :templates), + token_permission_types: diff_from_and_to(category: :token_permission_types), + tracker: @to_org_entries[:tracker].any? ? [] : @from_org_entries[:tracker], + users: diff_from_and_to(category: :users) + } + end + + # rubocop:disable Metrics/AbcSize + def diff_from_and_to(category:) + return [] unless category.present? && @from_org_entries.fetch(category, []).any? + + case category + when :departments + # Merge only the unique departments + existing = @to_org_entries[category].map { |e| e.name.downcase } + @from_org_entries[category].reject { |e| existing.include?(e.name.downcase) } + when :identifiers + # Merge identifiers with no identifier_scheme + # Retain the to_org's identifiers for specific identifier_schemes + schemes = @to_org_entries[category].collect(&:identifier_scheme) + @from_org_entries[category].reject do |entry| + entry.identifier_scheme.present? && schemes.include?(entry.identifier_scheme) + end + when :token_permission_types + # Merge only the unique token_permission_types + existing = @to_org_entries[category].map { |e| e.token_type.downcase } + @from_org_entries[category].reject { |e| existing.include?(e.token_type.downcase) } + when :users + # Merge only the unique users + existing = @to_org_entries[category].map { |e| e.email.downcase } + @from_org_entries[category].reject { |e| existing.include?(e.email.downcase) } + else + @from_org_entries[category] + end + end + # rubocop:enable Metrics/AbcSize + + def org_attributes(org:) + return {} unless org.is_a?(Org) + + { + contact_email: org.contact_email, + contact_name: org.contact_name, + feedback_email_msg: org.feedback_email_msg, + feedback_email_subject: org.feedback_email_subject, + feedback_enabled: org.feedback_enabled, + managed: org.managed, + links: org.links, + logo_name: org.logo_name, + logo_uid: org.logo_uid, + target_url: org.target_url + } + end + + # rubocop:disable Metrics/AbcSize + def mergeable_columns + out = {} + out[:target_url] = @from_org.target_url if mergeable_column?(column: :target_url) + out[:managed] = @from_org.managed if mergeable_column?(column: :managed) + out[:links] = @from_org.links if mergeable_column?(column: :links) + + if mergeable_column?(column: :logo) + out[:logo_uid] = @from_org.logo_uid + out[:logo_name] = @from_org.logo_name + end + if mergeable_column?(column: :contact_email) + out[:contact_email] = @from_org.contact_email + out[:contact_name] = @from_org.contact_name + end + if mergeable_column?(column: :feedback_enabled) + out[:feedback_enabled] = @from_org.feedback_enabled + out[:feedback_email_subject] = @from_org.feedback_email_subject + out[:feedback_email_msg] = @from_org.feedback_email_msg + end + out + end + # rubocop:enable Metrics/AbcSize + + def mergeable_column?(column:) + case column + when :links + (@to_org.links.nil? || @to_org.links.fetch("org", []).empty?) && + (@from_org.links.present? || @from_org.links.fetch("org", [].any?)) + when :managed + !@to_org.managed? && @from_org.managed? + when :feedback_enabled + !@to_org.feedback_enabled? && @from_org.feedback_enabled? + else + @to_org.send(column).nil? && + @from_org.send(column).present? && + @to_org != @from_org + end + end + + end + + end + +end diff --git a/app/views/orgs/_merge_form.html.erb b/app/views/orgs/_merge_form.html.erb new file mode 100644 index 0000000000..bd04cfbefe --- /dev/null +++ b/app/views/orgs/_merge_form.html.erb @@ -0,0 +1,26 @@ +<%# locals: org, analysis %> + +

<%= _("Merge Organisations") %>

+ +
+
+

<%= sanitize(_("Please select the Organisation that will replace the current organisation: '%{org_name}'.") % { org_name: org.name }) %>

+

<%= _(" Then click the 'analyze' button to review the proposed changes. Note that no changes will take place until you have reviewed and approve of the changes.") %>

+ <%= form_for org, url: merge_analyze_super_admin_org_path(org), method: :post, remote: true do |form| %> + <%= render partial: "shared/org_selectors/local_only", + locals: { + form: form, + id_field: :id, + default_org: nil, + orgs: Org.includes(identifiers: :identifier_scheme).all.reject { |o| o == org }, + required: true + } %> + <%= form.button(_('Analyze'), class: "btn btn-primary", type: "submit") %> + <% end %> +
+
+ +
+
+
+
diff --git a/app/views/orgs/admin_edit.html.erb b/app/views/orgs/admin_edit.html.erb index 2d37230ce4..df5f67f0c8 100644 --- a/app/views/orgs/admin_edit.html.erb +++ b/app/views/orgs/admin_edit.html.erb @@ -25,6 +25,12 @@
  • <%= _('Schools/Departments') %>
  • + + <% if current_user.can_super_admin? %> +
  • + <%= _('Merge') %> +
  • + <% end %> @@ -61,6 +67,18 @@ + + <% if current_user.can_super_admin? %> +
    +
    +
    +
    + <%= render partial: 'orgs/merge_form', locals: { org: org, analysis: nil } %> +
    +
    +
    +
    + <% end %> diff --git a/app/views/super_admin/orgs/_analysis.html.erb b/app/views/super_admin/orgs/_analysis.html.erb new file mode 100644 index 0000000000..42bad41c4a --- /dev/null +++ b/app/views/super_admin/orgs/_analysis.html.erb @@ -0,0 +1,98 @@ +<%# locals: org, target_org %> + +<% +presenter = SuperAdmin::Orgs::MergePresenter.new(from_org: org, to_org: target_org) +orcid = IdentifierScheme.find_by(name: 'orcid') +%> +<%= form_for org, url: merge_commit_super_admin_org_path(org), method: :post, remote: false do |form| %> + +

    <%= _("Merging '%{from_org_name}' into '%{to_org_name}'") % { from_org_name: org.name, to_org_name: target_org.name } %>

    + +

    <%= _("Summary:") %>

    +

    <%= _("Counts of '%{to_org_name}' records before and after a merge with %{from_org_name}.") % { to_org_name: target_org.name, from_org_name: org.name } %>

    +
    + + + + + + + + + + <% presenter.categories.each do |category| %> + <% + to_entries = presenter.to_org_entries[category] + mergeable_entries = presenter.mergeable_entries[category] + %> + + + + + + <% end %> + +
    <%= _("Record Type") %><%= _("'%{to_org_name}' Count - before merge") % { to_org_name: presenter.to_org_name } %><%= _("'%{to_org_name}' Count - after merge") % { to_org_name: presenter.to_org_name } %>
    <%= category.capitalize %><%= to_entries.length %><%= to_entries.length + mergeable_entries.length %>
    +
    + +

    <%= _("Details:") %>

    +

    <%= _("The table below shows the specific changes that will occur during the merge process. Once the merge is complete, '%{from_org_name}' will be deleted. You can then consolidate/correct any merge issues (e.g. remove any departments that were duplicated)") % { from_org_name: org.name } %>

    +
    + + + + + + + + + + + + <% + org_merge_cols = presenter.mergeable_attributes + org_from_cols = presenter.from_org_attributes.select { |col| org_merge_cols.include?(col) } + org_to_cols = presenter.to_org_attributes.select { |col| org_merge_cols.include?(col) } + %> + + + + + + + <% presenter.categories.each do |category| %> + <% + from_entries = presenter.from_org_entries[category] + to_entries = presenter.to_org_entries[category] + mergeable_entries = presenter.mergeable_entries[category] + %> + <% next unless mergeable_entries.length > 0 %> + + + + + + + + <% end %> + +
    <%= _('Record Type') %><%= _("%{from_org_name} - Before") % { from_org_name: presenter.from_org_name } %><%= _("%{to_org_name} - Before") % { to_org_name: presenter.to_org_name } %><%= _("Action") %>
    <%= _("Org") %><%= sanitize(org_column_content(attributes: org_from_cols)) %><%= sanitize(org_column_content(attributes: org_to_cols)) %> +

    <%= _("The following attributes will be updated") if org_merge_cols.any? %>

    + <%= sanitize(org_column_content(attributes: org_merge_cols)) %> +
    <%= category.capitalize %><%= sanitize(column_content(entries: from_entries, orcid: orcid)) %><%= sanitize(column_content(entries: to_entries, orcid: orcid)) %> + <%= sanitize(merge_column_content( + entries: mergeable_entries, orcid: orcid, to_org_name: presenter.to_org_name) + ) %> +
    +
    + + <%= form.hidden_field :target_org, value: target_org.id %> + +

    <%= _("Warning: The changes proposed above will be committed to the database. '%{org_name}' will then be deleted.") % { org_name: org.name } %>

    +

    <%= _("These changes cannot be undone! Do not proceed unless you are certain.") %>

    +

    <%= _("You may find it helpful to make a note of the expected number of records for each category that will be merged before continuing.") %>

    + <% if org.plans.select(&:feedback_requested?).any? %> +

    <%= _("Warning: '%{from_org_name}' has Plans that are currently in the review process. The Org admins may not be able to see these plans after the merge!") % { from_org_name: org.name } %>

    + <% end %> + <%= form.button(_('Merge records'), class: "btn btn-primary", type: "submit") %> +<% end %> diff --git a/app/views/super_admin/orgs/merge_analyze.js.erb b/app/views/super_admin/orgs/merge_analyze.js.erb new file mode 100644 index 0000000000..5b0deab976 --- /dev/null +++ b/app/views/super_admin/orgs/merge_analyze.js.erb @@ -0,0 +1,9 @@ +var target = $('#merge-analysis'); + +if (target != undefined) { + <% if @org.present? && @target_org.present? %> + target.html(`<%= escape_javascript(render partial: "super_admin/orgs/analysis", locals: { org: @org, target_org: @target_org }) %>`); + <% else %> + target.html(`<%= _("Something went wrong and we were unable to analyze a merge between these two organisations.") %>`); + <% end %> +} diff --git a/config/routes.rb b/config/routes.rb index fb0a52c57c..1960a85ec2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -323,7 +323,13 @@ end namespace :super_admin do - resources :orgs, only: %i[index new create destroy] + resources :orgs, only: %i[index new create destroy] do + member do + post "merge_analyze" + post "merge_commit" + end + end + resources :themes, only: %i[index new create edit update destroy] resources :users, only: %i[edit update] do member do diff --git a/db/migrate/20201208192403_drop_org_identifiers.rb b/db/migrate/20201208192403_drop_org_identifiers.rb new file mode 100644 index 0000000000..de935890af --- /dev/null +++ b/db/migrate/20201208192403_drop_org_identifiers.rb @@ -0,0 +1,5 @@ +class DropOrgIdentifiers < ActiveRecord::Migration[5.2] + def change + drop_table :org_identifiers + end +end diff --git a/db/schema.rb b/db/schema.rb index 29221ac1d9..f441b7db3c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2020_11_19_210343) do +ActiveRecord::Schema.define(version: 2020_12_08_192403) do create_table "annotations", id: :integer, force: :cascade do |t| t.integer "question_id" @@ -200,17 +200,6 @@ t.boolean "enabled", default: true end - create_table "org_identifiers", id: :integer, force: :cascade do |t| - t.string "identifier" - t.integer "identifier_scheme_id" - t.string "attrs" - t.datetime "created_at" - t.datetime "updated_at" - t.integer "org_id" - t.index ["identifier_scheme_id"], name: "fk_rails_189ad2e573" - t.index ["org_id"], name: "fk_rails_36323c0674" - end - create_table "org_token_permissions", id: :integer, force: :cascade do |t| t.integer "org_id" t.integer "token_permission_type_id" @@ -553,8 +542,6 @@ add_foreign_key "notes", "users" add_foreign_key "notification_acknowledgements", "notifications" add_foreign_key "notification_acknowledgements", "users" - add_foreign_key "org_identifiers", "identifier_schemes" - add_foreign_key "org_identifiers", "orgs" add_foreign_key "org_token_permissions", "orgs" add_foreign_key "org_token_permissions", "token_permission_types" add_foreign_key "orgs", "languages" diff --git a/spec/controllers/super_admin/orgs_controller_spec.rb b/spec/controllers/super_admin/orgs_controller_spec.rb new file mode 100644 index 0000000000..a463355a86 --- /dev/null +++ b/spec/controllers/super_admin/orgs_controller_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SuperAdmin::OrgsController, type: :controller do + + before(:each) do + @scheme = create(:identifier_scheme) + tpt = create(:token_permission_type) + @from_org = create(:org, :funder, templates: 1, plans: 2, managed: true, + feedback_enabled: true, + token_permission_types: [tpt]) + create(:annotation, org: @from_org) + create(:department, org: @from_org) + gg = @from_org.guidance_groups.first if @from_org.guidance_groups.any? + gg = create(:guidance_group, org: @from_org) unless gg.present? + create(:guidance, guidance_group: gg) + create(:identifier, identifiable: @from_org, identifier_scheme: nil) + create(:identifier, identifiable: @from_org, identifier_scheme: @scheme) + create(:plan, funder: @from_org) + create(:tracker, org: @from_org) + create(:user, org: @from_org) + + @from_org.reload + @to_org = create(:org, :institution, plans: 2, managed: false) + @user = create(:user, :super_admin) + + @controller = described_class.new + sign_in(@user) + end + + describe "POST /super_admin/:id/merge_analyze", js: true do + before(:each) do + @params = { + "id": @from_org.id, + # Send over the Org typehead json in the org.id field so the service can unpackage it + "org": { "id": { "id": @to_org.id, "name": @to_org.name }.to_json } + } + end + + it "fails if user is not a super admin" do + sign_in(create(:user)) + post :merge_analyze, params: @params + expect(response.code).to eql("302") + expect(response).to redirect_to(plans_path) + expect(flash[:alert].present?).to eql(true) + end + it "succeeds in analyzing the Orgs" do + post :merge_analyze, params: @params, format: :js + expect(response.code).to eql("200") + expect(assigns(:org)).to eql(@from_org) + expect(assigns(:target_org)).to eql(@to_org) + expect(response).to render_template(:merge_analyze) + end + end + + describe "POST /super_admin/:id/merge_commit", js: true do + context "standard question type (no question_options and not RDA metadata)" do + before(:each) do + @params = { "id": @from_org.id, "org": { "target_org": @to_org.id } } + end + + it "fails if user is not a super admin" do + sign_in(create(:user)) + post :merge_commit, params: @params, format: :js + expect(response.code).to eql("302") + expect(response).to redirect_to(plans_path) + expect(flash[:alert].present?).to eql(true) + end + it "fails if :target_org is not found" do + @params[:org][:target_org] = 9999 + post :merge_commit, params: @params, format: :js + expect(response.code).to eql("302") + expect(response).to redirect_to(admin_edit_org_path(@from_org)) + expect(flash[:alert].present?).to eql(true) + end + it "succeeds and redirects properly" do + post :merge_commit, params: @params, format: :js + expect(response.code).to eql("302") + expect(response).to redirect_to(super_admin_orgs_path) + end + end + end + +end diff --git a/spec/factories/trackers.rb b/spec/factories/trackers.rb index 09a1926078..875480d4e1 100644 --- a/spec/factories/trackers.rb +++ b/spec/factories/trackers.rb @@ -3,6 +3,6 @@ FactoryBot.define do factory :tracker do org { nil } - code { "MyString" } + code { "UA-#{Faker::Number.number(digits: 5)}-#{Faker::Number.number(digits: 2)}" } end end diff --git a/spec/features/super_admins/merge_org_spec.rb b/spec/features/super_admins/merge_org_spec.rb new file mode 100644 index 0000000000..e9fed320cf --- /dev/null +++ b/spec/features/super_admins/merge_org_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "SuperAdmins Merge Orgs", type: :feature, js: true do + + before do + Org.destroy_all + @scheme = create(:identifier_scheme) + tpt = create(:token_permission_type) + @from_org = create(:org, :organisation, templates: 1, plans: 2, managed: true, + feedback_enabled: true, + token_permission_types: [tpt]) + create(:annotation, org: @from_org) + create(:department, org: @from_org) + gg = @from_org.guidance_groups.first if @from_org.guidance_groups.any? + gg = create(:guidance_group, org: @from_org) unless gg.present? + create(:guidance, guidance_group: gg) + create(:identifier, identifiable: @from_org, identifier_scheme: nil) + create(:identifier, identifiable: @from_org, identifier_scheme: @scheme) + create(:plan, funder: @from_org) + create(:tracker, org: @from_org) + create(:user, org: @from_org) + + @to_org = create(:org, :institution, plans: 2, managed: false) + + @user = create(:user, :super_admin, org: create(:org)) + sign_in(@user) + end + + scenario "Super admin merges an Org into another Org" do + org_name = @from_org.name + click_link "Admin" + sleep(0.5) + click_link "Organisations" + first("#org-#{@from_org.id}-actions").click + first("a[href=\"/org/admin/#{@from_org.id}/admin_edit\"]").click + + click_link "Merge" + sleep(0.3) + expect(page).to have_text("Merge Organisations") + find("#org_org_name").click + fill_in(:org_org_name, with: @to_org.name[0..6]) + sleep(0.5) + choose_suggestion(@to_org.name) + + click_button "Analyze" + # Wait for response + sleep(0.3) + expect(page).to have_text("Summary:") + + click_button "Merge records" + # Wait for redirect + sleep(0.3) + expect(page).to have_text("Organisations") + expect(page).to have_text("Successfully merged") + + # Make sure that the correct org was deleted + expect(Org.where(name: org_name).any?).to eql(false) + expect(Org.where(name: @to_org.name).any?).to eql(true) + + # Make sure the Org we merged is no longer findable + find("#search").click + fill_in(:search, with: org_name) + click_button "Search" + sleep(0.3) + expect(page).to have_text("There are no records associated") + + # Make sure the Org we merged into is findable + find("#search").click + fill_in(:search, with: @to_org.name) + click_button "Search" + sleep(0.3) + expect(page).to have_text(@to_org.name) + end + +end diff --git a/spec/models/guidance_group_spec.rb b/spec/models/guidance_group_spec.rb index 4a7123e370..2d2dbde30a 100644 --- a/spec/models/guidance_group_spec.rb +++ b/spec/models/guidance_group_spec.rb @@ -210,5 +210,45 @@ end end + + context ":merge!(to_be_merged:)" do + before(:each) do + org = create(:org) + @guidance_group = create(:guidance_group, org: org) + @to_be_merged = create(:guidance_group, org: org, plans: [create(:plan)], + guidances: [create(:guidance)]) + end + + it "returns false if to_be_merged is not a GuidanceGroup" do + result = @guidance_group.merge!(to_be_merged: build(:user)) + expect(result).to eql(@guidance_group) + end + it "occurs inside a transaction" do + GuidanceGroup.any_instance.stubs(:save).returns(false) + result = @guidance_group.merge!(to_be_merged: @to_be_merged) + expect(result).to eql(nil) + # Since the save will fail and we reload the Object it should be valid + expect(@guidance_group.valid?).to eql(true) + expect(@to_be_merged.reload.new_record?).to eql(false) + expect(@to_be_merged.guidances.length).not_to eql(0) + end + it "merges associated :plans" do + expected = @guidance_group.plans.length + @to_be_merged.plans.length + @guidance_group.merge!(to_be_merged: @to_be_merged) + expect(@guidance_group.plans.length).to eql(expected) + end + it "merges associated :guidances" do + expected = @guidance_group.guidances.length + @to_be_merged.guidances.length + @guidance_group.merge!(to_be_merged: @to_be_merged) + expect(@guidance_group.guidances.length).to eql(expected) + end + it "removes the :to_be_merged GuidanceGroup" do + original_id = @to_be_merged.id + expect(@guidance_group.merge!(to_be_merged: @to_be_merged)).to eql(@guidance_group) + expect(Guidance.where(guidance_group_id: original_id).any?).to eql(false) + expect(GuidanceGroup.find_by(id: original_id).present?).to eql(false) + end + end + end end diff --git a/spec/models/org_spec.rb b/spec/models/org_spec.rb index 48b12ee800..56f68a3215 100644 --- a/spec/models/org_spec.rb +++ b/spec/models/org_spec.rb @@ -463,4 +463,229 @@ end end + context ":merge!(to_be_merged:)" do + before(:each) do + @scheme = create(:identifier_scheme) + tpt = create(:token_permission_type) + @org = create(:org, :organisation) + + @to_be_merged = create(:org, :funder, templates: 1, plans: 2, managed: true, + token_permission_types: [tpt]) + create(:annotation, org: @to_be_merged) + create(:department, org: @to_be_merged) + gg = @to_be_merged.guidance_groups.first if @to_be_merged.guidance_groups.any? + gg = create(:guidance_group, org: @to_be_merged) unless gg.present? + create(:guidance, guidance_group: gg) + create(:identifier, identifiable: @to_be_merged, identifier_scheme: nil) + create(:identifier, identifiable: @to_be_merged, identifier_scheme: @scheme) + create(:plan, funder_id: @to_be_merged.id) + create(:tracker, org: @to_be_merged) + create(:user, org: @to_be_merged) + @to_be_merged.reload + end + + it "returns false if to_be_merged is not an Org" do + result = @org.merge!(to_be_merged: build(:user)) + expect(result).to eql(@org) + end + it "occurs inside a transaction" do + Org.any_instance.stubs(:save).returns(false) + result = @org.merge!(to_be_merged: @to_be_merged) + expect(result).to eql(nil) + # Since the save will fail and we reload the Object it should be valid + expect(@org.valid?).to eql(true) + expect(@to_be_merged.reload.new_record?).to eql(false) + expect(@to_be_merged.annotations.length).not_to eql(0) + end + it "merges attributes" do + original = @to_be_merged.dup + org = @org.merge!(to_be_merged: @to_be_merged) + expect(org.links).to eql(original.links) + end + it "merges associated :annotations" do + expected = @org.annotations.length + @to_be_merged.annotations.length + @org.merge!(to_be_merged: @to_be_merged) + expect(@org.annotations.length).to eql(expected) + end + it "merges associated :departments" do + expected = @org.departments.length + @to_be_merged.departments.length + @org.merge!(to_be_merged: @to_be_merged) + expect(@org.departments.length).to eql(expected) + end + it "merges associated :funded_plans" do + expected = @org.funded_plans.length + @to_be_merged.funded_plans.length + @org.merge!(to_be_merged: @to_be_merged) + expect(@org.funded_plans.length).to eql(expected) + end + it "merges associated :guidances" do + expected = (@org.guidance_groups.first&.guidances&.length || 0) + + @to_be_merged.guidance_groups.first.guidances.length + @org.merge!(to_be_merged: @to_be_merged) + expect(@org.guidance_groups.first.guidances.length).to eql(expected) + end + it "merges associated :identifiers" do + expected = @org.identifiers.length + @to_be_merged.identifiers.length + @org.merge!(to_be_merged: @to_be_merged) + expect(@org.identifiers.length).to eql(expected) + end + it "merges associated :plans" do + expected = @org.plans.length + @to_be_merged.plans.length + @org.merge!(to_be_merged: @to_be_merged) + expect(@org.plans.length).to eql(expected) + end + it "merges associated :templates" do + expected = @org.templates.length + @to_be_merged.templates.length + @org.merge!(to_be_merged: @to_be_merged) + expect(@org.templates.length).to eql(expected) + end + it "merges associated :token_permission_types" do + expected = @org.token_permission_types.length + @to_be_merged.token_permission_types.length + @org.merge!(to_be_merged: @to_be_merged) + expect(@org.token_permission_types.length).to eql(expected) + end + it "merges associated :tracker" do + expected = @to_be_merged.tracker.code + @org.merge!(to_be_merged: @to_be_merged) + expect(@org.tracker.code).to eql(expected) + end + it "merges associated :users" do + expected = @org.users.length + @to_be_merged.users.length + @org.merge!(to_be_merged: @to_be_merged) + expect(@org.users.length).to eql(expected) + end + it "removes the :to_be_merged Org" do + original_id = @to_be_merged.id + expect(@org.merge!(to_be_merged: @to_be_merged)).to eql(@org) + expect(Org.find_by(id: original_id).present?).to eql(false) + end + end + + context "private methods" do + describe ":merge_attributes!(to_be_merged:)" do + before(:each) do + @org = create(:org, :organisation, is_other: false, managed: false, + feedback_enabled: false, contact_email: nil, + contact_name: nil, feedback_email_msg: nil, + feedback_email_subject: nil) + + @to_be_merged = create(:org, :funder, templates: 1, plans: 2, managed: true, + feedback_enabled: true, is_other: true, + sort_name: Faker::Movies::StarWars.planet, + region: create(:region), + language: create(:language)) + end + + it "returns false unless Org is an Org" do + expect(@org.send(:merge_attributes!, to_be_merged: create(:user))).to eql(false) + end + it "merges the correct attributes" do + original = @to_be_merged.dup + org = @org.merge!(to_be_merged: @to_be_merged) + expect(org.managed?).to eql(original.managed?) + expect(org.links).to eql(original.links) + expect(org.target_url).to eql(original.target_url) + expect(org.logo).to eql(original.logo) + expect(org.contact_email).to eql(original.contact_email) + expect(org.contact_name).to eql(original.contact_name) + expect(org.feedback_enabled).to eql(original.feedback_enabled) + expect(org.feedback_email_msg).to eql(original.feedback_email_msg) + expect(org.feedback_email_subject).to eql(original.feedback_email_subject) + end + it "does not merge the attributes it should not merge" do + original = @to_be_merged.dup + org = @org.merge!(to_be_merged: @to_be_merged) + expect(org.abbreviation).not_to eql(original.abbreviation) + expect(org.is_other).not_to eql(original.is_other) + expect(org.name).not_to eql(original.name) + expect(org.organisation?).to eql(true) + expect(org.funder?).to eql(false) + expect(org.sort_name).not_to eql(original.sort_name) + expect(org.region).not_to eql(original.region) + expect(org.language).not_to eql(original.language) + end + end + + describe ":merge_departments!(to_be_merged:)" do + before(:each) do + @org = create(:org) + @to_be_merged = create(:org) + @department = create(:department, org: @to_be_merged) + @to_be_merged.reload + end + + it "returns false unless the specified Org is an Org" do + expect(@org.send(:merge_departments!, to_be_merged: create(:user))).to eql(false) + end + it "returns false unless the specified Org has token_permission_types" do + expect(@org.send(:merge_departments!, to_be_merged: create(:org))).to eql(false) + end + it "merges :departments that are not already associated" do + @org.send(:merge_departments!, to_be_merged: @to_be_merged) + @org.reload + expect(@org.departments.map(&:name).include?(@department.name)).to eql(true) + end + it "does not merge :departments that have the same name" do + create(:department, name: @department.name.downcase, org: @org) + @org.reload + @org.send(:merge_departments!, to_be_merged: @to_be_merged) + expect(@org.departments.length).to eql(1) + end + end + + describe ":merge_guidance_groups!(to_be_merged:)" do + before(:each) do + @guidance = create(:guidance) + @gg = create(:guidance_group, guidances: [@guidance]) + @org = create(:org, guidance_groups: []) + @to_be_merged = create(:org, guidance_groups: [@gg]) + end + + it "returns false unless the specified Org is an Org" do + expect(@org.send(:merge_guidance_groups!, to_be_merged: create(:user))).to eql(false) + end + it "returns false unless the specified Org has :guidance_groups" do + expect(@org.send(:merge_guidance_groups!, to_be_merged: create(:org))).to eql(false) + end + it "merges into the Org's existing :guidance_group" do + @org.update(guidance_groups: [create(:guidance_group, guidances: [])]) + @org.send(:merge_guidance_groups!, to_be_merged: @to_be_merged) + @org = @org.reload + expect(@org.guidance_groups.include?(@gg)).to eql(false) + expect(@org.guidance_groups.length).to eql(1) + expect(@org.guidance_groups.first.guidances.include?(@guidance)).to eql(true) + end + it "creates a new :guidance_group if the Org does not have one" do + @org.send(:merge_guidance_groups!, to_be_merged: @to_be_merged) + @org = @org.reload + expect(@org.guidance_groups.include?(@gg)).to eql(false) + expect(@org.guidance_groups.length).to eql(1) + expect(@org.guidance_groups.first.guidances.include?(@guidance)).to eql(true) + end + end + + describe ":merge_token_permission_types!(to_be_merged:)" do + before(:each) do + @tpt = create(:token_permission_type) + @org = create(:org) + @to_be_merged = create(:org, token_permission_types: [@tpt]) + end + + it "returns false unless the specified Org is an Org" do + expect(@org.send(:merge_token_permission_types!, to_be_merged: create(:user))).to eql(false) + end + it "returns false unless the specified Org has token_permission_types" do + expect(@org.send(:merge_token_permission_types!, to_be_merged: create(:org))).to eql(false) + end + it "merges :token_permission_types that are not already associated" do + @org.send(:merge_token_permission_types!, to_be_merged: @to_be_merged) + expect(@org.token_permission_types.include?(@tpt)).to eql(true) + end + it "does not merge :token_permission_types that are already associated" do + @org.update(token_permission_types: [@tpt]) + @org.send(:merge_token_permission_types!, to_be_merged: @to_be_merged) + expect(@org.token_permission_types.length).to eql(1) + end + end + end + end diff --git a/spec/presenters/super_admin/orgs/merge_presenter_spec.rb b/spec/presenters/super_admin/orgs/merge_presenter_spec.rb new file mode 100644 index 0000000000..b1265e5076 --- /dev/null +++ b/spec/presenters/super_admin/orgs/merge_presenter_spec.rb @@ -0,0 +1,289 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SuperAdmin::Orgs::MergePresenter do + + before(:each) do + @to_org = create(:org, :organisation, is_other: false, managed: false, + feedback_enabled: false, contact_email: nil, + contact_name: nil, feedback_email_msg: nil, + links: { org: [] }, feedback_email_subject: nil) + + @tpt = create(:token_permission_type) + @from_org = create(:org, :funder, templates: 1, plans: 0, managed: true, + feedback_enabled: true, is_other: true, + sort_name: Faker::Movies::StarWars.planet, + target_url: Faker::Internet.url, + contact_name: Faker::Music::PearlJam.musician, + contact_email: Faker::Internet.email, + links: { org: { foo: "bar" } }, + region: create(:region), + language: create(:language), + token_permission_types: [@tpt]) + create(:annotation, org: @from_org) + create(:department, org: @from_org) + gg = @to_be_merged.guidance_groups.first if @from_org.guidance_groups.any? + gg = create(:guidance_group, org: @from_org) unless gg.present? + create(:guidance, guidance_group: gg) + @id_no_scheme = create(:identifier, identifiable: @from_org, identifier_scheme: nil) + @scheme = create(:identifier_scheme) + @id_scheme = create(:identifier, identifiable: @from_org, identifier_scheme: @scheme) + create(:plan, funder_id: @from_org.id) + create(:plan, org: @from_org) + create(:tracker, org: @from_org) + @user = create(:user, org: @from_org) + @from_org.reload + @presenter = SuperAdmin::Orgs::MergePresenter.new(from_org: @from_org, to_org: @to_org) + end + + describe ":initialize(from_org:, to_org:)" do + it "sets the cropped :to_org and :from_org" do + expect(@presenter.to_org).to eql(@to_org) + expect(@presenter.from_org).to eql(@from_org) + end + it "sets the cropped :to_org_name and :from_org_name" do + expect(@presenter.to_org_name.present? && @presenter.to_org_name.is_a?(String)).to eql(true) + expect(@presenter.from_org_name.present? && @presenter.to_org_name.is_a?(String)).to eql(true) + end + it "sets the :categories" do + expect(@presenter.categories.any?).to eql(true) + expect(@presenter.categories.first).to eql(:annotations) + expect(@presenter.categories.last).to eql(:users) + end + it "sets the :to_org_entries, :from_org_entries and mergeable_entries" do + expect(@presenter.to_org_entries.is_a?(Hash)).to eql(true) + expect(@presenter.from_org_entries.is_a?(Hash)).to eql(true) + expect(@presenter.from_org_entries[:annotations].any?).to eql(true) + expect(@presenter.mergeable_entries.is_a?(Hash)).to eql(true) + expect(@presenter.mergeable_entries[:annotations].any?).to eql(true) + end + it "sets the :from_org_attributes, :to_org_attributes and :mergeable_attributes" do + expect(@presenter.to_org_attributes.is_a?(Hash)).to eql(true) + expect(@presenter.from_org_attributes.is_a?(Hash)).to eql(true) + expect(@presenter.from_org_attributes[:contact_email].present?).to eql(true) + expect(@presenter.mergeable_attributes.is_a?(Hash)).to eql(true) + expect(@presenter.mergeable_attributes[:target_url].present?).to eql(true) + end + end + + context "private methods" do + describe ":prepare_org(org:)" do + it "returns the expected categories for from_org" do + results = @presenter.send(:prepare_org, org: @from_org) + expect(results[:annotations].any?).to eql(true) + expect(results[:departments].any?).to eql(true) + expect(results[:funded_plans].any?).to eql(true) + expect(results[:guidances].any?).to eql(true) + expect(results[:identifiers].any?).to eql(true) + expect(results[:plans].any?).to eql(true) + expect(results[:templates].any?).to eql(true) + expect(results[:token_permission_types].any?).to eql(true) + expect(results[:tracker].any?).to eql(true) + expect(results[:users].any?).to eql(true) + end + it "returns the expected categories for to_org" do + results = @presenter.send(:prepare_org, org: @to_org) + expect(results[:annotations].any?).to eql(false) + expect(results[:departments].any?).to eql(false) + expect(results[:funded_plans].any?).to eql(false) + expect(results[:guidances].any?).to eql(false) + expect(results[:identifiers].any?).to eql(false) + expect(results[:plans].any?).to eql(false) + expect(results[:templates].any?).to eql(false) + expect(results[:token_permission_types].any?).to eql(false) + expect(results[:tracker].any?).to eql(false) + expect(results[:users].any?).to eql(false) + end + end + + describe ":prepare_mergeables" do + it "returns the expected categories" do + results = @presenter.send(:prepare_mergeables) + expect(results[:annotations].any?).to eql(true) + expect(results[:departments].any?).to eql(true) + expect(results[:funded_plans].any?).to eql(true) + expect(results[:guidances].any?).to eql(true) + expect(results[:identifiers].any?).to eql(true) + expect(results[:plans].any?).to eql(true) + expect(results[:templates].any?).to eql(true) + expect(results[:token_permission_types].any?).to eql(true) + expect(results[:tracker].any?).to eql(true) + expect(results[:users].any?).to eql(true) + end + it "does not return :tracker if one is already defined on to_org" do + create(:tracker, org: @to_org) + @to_org.reload + @presenter = SuperAdmin::Orgs::MergePresenter.new(from_org: @from_org, to_org: @to_org) + results = @presenter.send(:prepare_mergeables) + expect(results[:tracker]).to eql([]) + end + end + + describe ":diff_from_and_to(category:)" do + before(:each) do + @entries = %i[annotations departments funded_plans guidances identifiers + plans templates token_permission_types tracker users] + end + it "returns an empty array if category is not present" do + expect(@presenter.send(:diff_from_and_to, category: nil)).to eql([]) + end + it "returns an empty array if category is not an org entry" do + expect(@presenter.send(:diff_from_and_to, category: :foo)).to eql([]) + end + it "returns the from_org entries for :annotations" do + results = @presenter.send(:diff_from_and_to, category: :annotations) + expect(results).to eql(@from_org.annotations.to_a) + end + it "returns the uniquie entries for :departments" do + dup_department = build(:department) + @from_org.departments << dup_department + @to_org.departments << dup_department + @presenter = SuperAdmin::Orgs::MergePresenter.new(from_org: @from_org, to_org: @to_org) + results = @presenter.send(:diff_from_and_to, category: :departments) + expect(results.include?(dup_department)).to eql(false) + expect(results).to eql(@from_org.departments.reject { |dpt| dpt == dup_department }) + end + it "returns the from_org entries for :funded_plans" do + results = @presenter.send(:diff_from_and_to, category: :funded_plans) + expect(results).to eql(@from_org.funded_plans.to_a) + end + it "returns the all entries for :guidances" do + results = @presenter.send(:diff_from_and_to, category: :guidances) + expect(results).to eql(@from_org.guidance_groups.map(&:guidances).flatten.to_a) + end + it "returns the :identifiers that have no :identifier_scheme" do + to_id = build(:identifier, identifier_scheme: nil) + @to_org.identifiers << to_id + @presenter = SuperAdmin::Orgs::MergePresenter.new(from_org: @from_org, to_org: @to_org) + results = @presenter.send(:diff_from_and_to, category: :identifiers) + expect(results.include?(@id_no_scheme)).to eql(true) + end + it "returns the :identifiers that are not defined in to_org for the :identifier_scheme" do + to_id = build(:identifier, identifier_scheme: build(:identifier_scheme)) + @to_org.identifiers << to_id + @presenter = SuperAdmin::Orgs::MergePresenter.new(from_org: @from_org, to_org: @to_org) + results = @presenter.send(:diff_from_and_to, category: :identifiers) + expect(results.include?(@id_scheme)).to eql(true) + end + it "does not return the :identifiers that are defined in to_org for the :identifier_scheme" do + to_id = build(:identifier, identifier_scheme: @scheme) + @to_org.identifiers << to_id + @presenter = SuperAdmin::Orgs::MergePresenter.new(from_org: @from_org, to_org: @to_org) + results = @presenter.send(:diff_from_and_to, category: :identifiers) + expect(results.include?(@id_scheme)).to eql(false) + end + it "returns the from_org entries for :plans" do + results = @presenter.send(:diff_from_and_to, category: :plans) + expect(results).to eql(Plan.where(org: @from_org).to_a) + end + it "returns the from_org entries for :templates" do + results = @presenter.send(:diff_from_and_to, category: :templates) + expect(results).to eql(@from_org.templates.to_a) + end + it "returns the :token_permission_types that are not defined on to_org" do + results = @presenter.send(:diff_from_and_to, category: :token_permission_types) + expect(results).to eql(@from_org.token_permission_types.to_a) + end + it "does not return the :token_permission_types that are defined on to_org" do + @to_org.token_permission_types << @tpt + @presenter = SuperAdmin::Orgs::MergePresenter.new(from_org: @from_org, to_org: @to_org) + results = @presenter.send(:diff_from_and_to, category: :token_permission_types) + expect(results.include?(@tpt)).to eql(false) + end + it "returns the from_org entries for :users not defined on to_org" do + results = @presenter.send(:diff_from_and_to, category: :users) + expect(results).to eql(@from_org.users.to_a) + end + it "does not return the :users that are defined on to_org" do + @to_org.users << @user + @presenter = SuperAdmin::Orgs::MergePresenter.new(from_org: @from_org, to_org: @to_org) + results = @presenter.send(:diff_from_and_to, category: :users) + expect(results.include?(@user)).to eql(false) + end + end + + describe ":org_attributes(org:)" do + before(:each) do + @expected = %i[target_url managed links + contact_name contact_email + logo_uid logo_name + feedback_enabled feedback_email_msg feedback_email_subject] + @results = @presenter.send(:org_attributes, org: @from_org) + end + it "returns an empty hash if :org is not an Org" do + expect(@presenter.send(:org_attributes, org: build(:user))).to eql({}) + end + it "includes the expected columns" do + @expected.each { |column| expect(@results[column]).to eql(@from_org.send(column)) } + end + it "does not include any other columns than the ones we expect" do + @results.each_key { |key| expect(@expected.include?(key)).to eql(true) } + end + end + + describe ":mergeable_columns" do + before(:each) do + @expected = %i[target_url managed links + contact_name contact_email + feedback_enabled feedback_email_msg feedback_email_subject] + @results = @presenter.send(:mergeable_columns) + end + it "includes the expected columns" do + @expected.each { |column| expect(@results[column].present?).to eql(true) } + end + it "does not include any other columns than the ones we expect" do + @results.each_key { |key| expect(@expected.include?(key)).to eql(true) } + end + end + describe ":mergeable_column?(column:)" do + it "returns false if the :to_org is :managed but the :from_org is not" do + @presenter.to_org.stubs(:managed?).returns(true) + @presenter.from_org.stubs(:managed?).returns(false) + expect(@presenter.send(:mergeable_column?, column: :managed)).to eql(false) + end + it "returns false if the :to_org has :feedback_enabled but the :from_org does not" do + @presenter.to_org.stubs(:feedback_enabled?).returns(true) + @presenter.from_org.stubs(:feedback_enabled?).returns(false) + expect(@presenter.send(:mergeable_column?, column: :feedback_enabled)).to eql(false) + end + it "returns false if the :from_org links is an empty json" do + @presenter.from_org.stubs(:links).returns({}) + expect(@presenter.send(:mergeable_column?, column: :links)).to eql(false) + end + it "returns false if the :to_org already has a value" do + @presenter.to_org.stubs(:contact_email).returns(Faker::Internet.email) + expect(@presenter.send(:mergeable_column?, column: :contact_email)).to eql(false) + end + it "returns false if the :from_org does not have a value" do + @presenter.from_org.stubs(:contact_email).returns(nil) + expect(@presenter.send(:mergeable_column?, column: :contact_email)).to eql(false) + end + it "returns true for :target_url" do + expect(@presenter.send(:mergeable_column?, column: :target_url)).to eql(true) + end + it "returns true for :managed" do + expect(@presenter.send(:mergeable_column?, column: :managed)).to eql(true) + end + it "returns true for :links" do + expect(@presenter.send(:mergeable_column?, column: :links)).to eql(true) + end + it "returns true for :contact_name" do + expect(@presenter.send(:mergeable_column?, column: :contact_name)).to eql(true) + end + it "returns true for :contact_email" do + expect(@presenter.send(:mergeable_column?, column: :contact_email)).to eql(true) + end + it "returns true for :feedback_enabled" do + expect(@presenter.send(:mergeable_column?, column: :feedback_enabled)).to eql(true) + end + it "returns true for :feedback_email_msg" do + expect(@presenter.send(:mergeable_column?, column: :feedback_email_msg)).to eql(true) + end + it "returns true for :feedback_email_subject" do + expect(@presenter.send(:mergeable_column?, column: :feedback_email_subject)).to eql(true) + end + end + end + +end From 177f66ebe3fea6fa3a3bbe023c3005ba7f180e4b Mon Sep 17 00:00:00 2001 From: briri Date: Wed, 10 Feb 2021 08:30:58 -0800 Subject: [PATCH 02/43] fix to plan merges for org.merge --- app/models/org.rb | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/models/org.rb b/app/models/org.rb index 2e0d7ad008..65db6ac1de 100644 --- a/app/models/org.rb +++ b/app/models/org.rb @@ -313,7 +313,12 @@ def merge!(to_be_merged:) to_be_merged.funded_plans.update_all(funder_id: id) merge_guidance_groups!(to_be_merged: to_be_merged) consolidate_identifiers!(array: to_be_merged.identifiers) - to_be_merged.plans.update_all(org_id: id) + + # TODO: Use this commented out version once we've stopped overriding the + # default association method called `plans` on this model + # to_be_merged.plans.update_all(org_id: id) + Plan.where(org_id: to_be_merged.id).update_all(org_id: id) + to_be_merged.templates.update_all(org_id: id) merge_token_permission_types!(to_be_merged: to_be_merged) self.tracker = to_be_merged.tracker unless tracker.present? From 2fcab79df6a370f6a2f9d81f10e5d9931e18fde2 Mon Sep 17 00:00:00 2001 From: briri Date: Wed, 10 Mar 2021 10:02:17 -0800 Subject: [PATCH 03/43] fixed some rubocop issues --- app/policies/org_policy.rb | 2 +- app/presenters/super_admin/orgs/merge_presenter.rb | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/app/policies/org_policy.rb b/app/policies/org_policy.rb index bb7c678004..8a927ced14 100644 --- a/app/policies/org_policy.rb +++ b/app/policies/org_policy.rb @@ -58,5 +58,5 @@ def merge_analyze? def merge_commit? user.can_super_admin? end - + end diff --git a/app/presenters/super_admin/orgs/merge_presenter.rb b/app/presenters/super_admin/orgs/merge_presenter.rb index 5f6f8bb01b..2ac24beb01 100644 --- a/app/presenters/super_admin/orgs/merge_presenter.rb +++ b/app/presenters/super_admin/orgs/merge_presenter.rb @@ -33,7 +33,6 @@ def initialize(from_org:, to_org:) private - # rubocop:disable Metrics/AbcSize def prepare_org(org:) return {} unless org.present? && org.is_a?(Org) @@ -53,7 +52,6 @@ def prepare_org(org:) users: org.users.sort { |a, b| a.email <=> b.email } } end - # rubocop:enable Metrics/AbcSize def prepare_mergeables return {} unless @from_org_entries.any? && @to_org_entries.any? @@ -119,7 +117,6 @@ def org_attributes(org:) } end - # rubocop:disable Metrics/AbcSize def mergeable_columns out = {} out[:target_url] = @from_org.target_url if mergeable_column?(column: :target_url) @@ -141,7 +138,6 @@ def mergeable_columns end out end - # rubocop:enable Metrics/AbcSize def mergeable_column?(column:) case column From e398d3ba675dc8f1a0832d76dc19cc58eaa76fa2 Mon Sep 17 00:00:00 2001 From: Nicolas Franck Date: Fri, 26 Mar 2021 16:53:09 +0100 Subject: [PATCH 04/43] fix issue https://github.com/DMPRoadmap/roadmap/issues/2843 --- app/controllers/plans_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/plans_controller.rb b/app/controllers/plans_controller.rb index 8563c1f117..c424f5d3bc 100644 --- a/app/controllers/plans_controller.rb +++ b/app/controllers/plans_controller.rb @@ -257,7 +257,7 @@ def update if @plan.update(attrs) # _attributes(attrs) format.html do - redirect_to plan_contributors_path(@plan), + redirect_to plan_path(@plan), notice: success_message(@plan, _("saved")) end format.json do From 4e44e78f865ab0546275fc5091ac7b9d9557e1af Mon Sep 17 00:00:00 2001 From: Nicolas Franck Date: Fri, 26 Mar 2021 17:27:22 +0100 Subject: [PATCH 05/43] fix issue 2845 --- app/controllers/plans_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/plans_controller.rb b/app/controllers/plans_controller.rb index 8563c1f117..0b9a571574 100644 --- a/app/controllers/plans_controller.rb +++ b/app/controllers/plans_controller.rb @@ -124,7 +124,7 @@ def create elsif !@plan.template.customization_of.nil? # We used a customized version of the the funder template # rubocop:disable Layout/LineLength - msg += " #{_('This plan is based on the')} #{@plan.funder&.name}: '#{@plan.template.title}' #{_('template with customisations by the')} #{plan_params[:org_name]}" + msg += " #{_('This plan is based on the')} #{@plan.funder&.name}: '#{@plan.template.title}' #{_('template with customisations by the')} #{@plan.template.org.name}" # rubocop:enable Layout/LineLength else # We used the specified org's or funder's template From dc90f254aed821ff8cdfa9ece22d6350ec96ddb2 Mon Sep 17 00:00:00 2001 From: briri Date: Thu, 1 Apr 2021 05:32:16 -0700 Subject: [PATCH 06/43] fixed rubocop complaint --- app/services/external_apis/base_service.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/external_apis/base_service.rb b/app/services/external_apis/base_service.rb index 920fdebb06..bd13298aa8 100644 --- a/app/services/external_apis/base_service.rb +++ b/app/services/external_apis/base_service.rb @@ -87,7 +87,7 @@ def app_name # Retrieves the helpdesk email from dmproadmap.rb initializer or uses the contact page url def app_email - dflt = Rails.application.routes.url_helpers.contact_us_url || '' + dflt = Rails.application.routes.url_helpers.contact_us_url || "" Rails.configuration.x.organisation.fetch(:helpdesk_email, dflt) end From 72265114a6f4d87af0c28aa9632e1d10c3bd8701 Mon Sep 17 00:00:00 2001 From: Nicolas Franck Date: Fri, 2 Apr 2021 16:43:04 +0200 Subject: [PATCH 07/43] further optimize queries --- app/controllers/plans_controller.rb | 11 ++++++++++- app/helpers/conditions_helper.rb | 27 +++++++++++++-------------- app/models/question.rb | 11 +++++++---- app/models/section.rb | 4 ++-- app/presenters/guidance_presenter.rb | 4 ++-- 5 files changed, 34 insertions(+), 23 deletions(-) diff --git a/app/controllers/plans_controller.rb b/app/controllers/plans_controller.rb index c53fd0b852..ea2e137a8b 100644 --- a/app/controllers/plans_controller.rb +++ b/app/controllers/plans_controller.rb @@ -228,7 +228,16 @@ def show # doing this when we refactor the Plan editing UI # GET /plans/:plan_id/phases/:id/edit def edit - plan = Plan.includes({ template: { phases: { sections: :questions } } }, { answers: :notes }) + plan = Plan.includes( + { template: { + phases: { + sections: { + questions: %i[question_format question_options annotations themes] + } + } + } }, + { answers: :notes } + ) .find(params[:id]) authorize plan phase_id = params[:phase_id].to_i diff --git a/app/helpers/conditions_helper.rb b/app/helpers/conditions_helper.rb index 5e31d66e5b..a674017fdc 100644 --- a/app/helpers/conditions_helper.rb +++ b/app/helpers/conditions_helper.rb @@ -71,9 +71,9 @@ def num_section_answers(plan, section) count = 0 plan_remove_list = remove_list(plan) plan.answers.each do |answer| - next unless answer.question.section.id == section.id && - !plan_remove_list.include?(answer.question.id) && - section.answered_questions(plan).include?(answer) && + next unless answer.question.section_id == section.id && + !plan_remove_list.include?(answer.question_id) && + section.question_ids.include?(answer.question_id) && answer.answered? count += 1 @@ -82,28 +82,27 @@ def num_section_answers(plan, section) end # number of questions in a section after update with conditions - # rubocop:disable Metrics/AbcSize def num_section_questions(plan, section, phase = nil) # when section and phase are a hash in exports if section.is_a?(Hash) && !phase.nil? && plan.is_a?(Plan) - phase_id = plan.phases.select { |ph| ph.number == phase[:number] }.first.id - section = plan.sections - .select { |s| s.phase_id == phase_id && s.title == section[:title] } - .first + + phase = plan.template + .phases + .select { |ph| ph.number == phase[:number] } + .first + section = phase.sections + .select { |s| s.phase_id == phase.id && s.title == section[:title] } + .first end count = 0 plan_remove_list = remove_list(plan) - plan.questions.each do |question| - if question.section.id == section.id && - !plan_remove_list.include?(question.id) - count += 1 - end + section.questions.each do |question| + count += 1 unless plan_remove_list.include?(question.id) end count end - # rubocop:enable Metrics/AbcSize # returns an array of hashes of section_id, number of section questions, and # number of section answers diff --git a/app/models/question.rb b/app/models/question.rb index c7ccfa0187..a496b0cf38 100644 --- a/app/models/question.rb +++ b/app/models/question.rb @@ -166,9 +166,10 @@ def guidance_for_org(org) # # Returns ActiveRecord::Relation def example_answers(org_ids) - annotations.where(org_id: Array(org_ids), - type: Annotation.types[:example_answer]) - .order(:created_at) + org_ids = Array(org_ids) + annotations.select { |a| org_ids.include?(a.org_id) } + .select { |a| a.type == Annotation.types[:example_answer] } + .sort { |a, b| a.created_at <=> b.created_at } end alias get_example_answers example_answers @@ -183,7 +184,9 @@ def example_answers(org_ids) # # Returns Annotation def guidance_annotation(org_id) - annotations.where(org_id: org_id, type: Annotation.types[:guidance]).first + annotations.select { |a| a.org_id == org_id } + .select { |a| a.type == Annotation.types[:guidance] } + .first end alias get_guidance_annotation guidance_annotation diff --git a/app/models/section.rb b/app/models/section.rb index eaa2697bc5..4017e7438b 100644 --- a/app/models/section.rb +++ b/app/models/section.rb @@ -113,8 +113,8 @@ def num_answered_questions(plan) def answered_questions(plan) return [] if plan.nil? - plan.answers.includes({ question: :question_format }, :question_options) - .where(question_id: question_ids) + plan.answers + .select { |answer| question_ids.include?(answer.question_id) } .to_a end diff --git a/app/presenters/guidance_presenter.rb b/app/presenters/guidance_presenter.rb index e3c128e090..c2acdd6b9a 100644 --- a/app/presenters/guidance_presenter.rb +++ b/app/presenters/guidance_presenter.rb @@ -113,9 +113,9 @@ def guidance_annotations?(org: nil, question: nil) # structure: # { guidance_group: { theme: [guidance, ...], ... }, ... } def guidance_groups_by_theme(org: nil, question: nil) - raise ArgumentError unless question.respond_to?(:themes) + raise ArgumentError unless question.is_a?(Question) + raise ArgumentError unless org.is_a?(Org) - question = Question.includes(:themes).find(question.id) return {} unless hashified_guidance_groups.key?(org) hashified_guidance_groups[org].each_key.each_with_object({}) do |gg, acc| From 4f4619cd89f504f1ef651112061c84c334dc443b Mon Sep 17 00:00:00 2001 From: Nicolas Franck Date: Fri, 2 Apr 2021 17:07:23 +0200 Subject: [PATCH 08/43] fix typo --- app/models/question.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/question.rb b/app/models/question.rb index a496b0cf38..04edb6c09a 100644 --- a/app/models/question.rb +++ b/app/models/question.rb @@ -168,7 +168,7 @@ def guidance_for_org(org) def example_answers(org_ids) org_ids = Array(org_ids) annotations.select { |a| org_ids.include?(a.org_id) } - .select { |a| a.type == Annotation.types[:example_answer] } + .select { |a| a.example_answer? } .sort { |a, b| a.created_at <=> b.created_at } end @@ -185,7 +185,7 @@ def example_answers(org_ids) # Returns Annotation def guidance_annotation(org_id) annotations.select { |a| a.org_id == org_id } - .select { |a| a.type == Annotation.types[:guidance] } + .select { |a| a.guidance? } .first end From ddb15d181457529ae72c66bd59661b00b11ce0e8 Mon Sep 17 00:00:00 2001 From: Nicolas Franck Date: Fri, 2 Apr 2021 17:33:59 +0200 Subject: [PATCH 09/43] make rubocop less grumpy --- app/models/question.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/models/question.rb b/app/models/question.rb index 04edb6c09a..2deb90a5a1 100644 --- a/app/models/question.rb +++ b/app/models/question.rb @@ -168,8 +168,8 @@ def guidance_for_org(org) def example_answers(org_ids) org_ids = Array(org_ids) annotations.select { |a| org_ids.include?(a.org_id) } - .select { |a| a.example_answer? } - .sort { |a, b| a.created_at <=> b.created_at } + .select(&:example_answer?) + .sort { |a, b| a.created_at <=> b.created_at } end alias get_example_answers example_answers @@ -185,7 +185,7 @@ def example_answers(org_ids) # Returns Annotation def guidance_annotation(org_id) annotations.select { |a| a.org_id == org_id } - .select { |a| a.guidance? } + .select(&:guidance?) .first end From 514a8aa6d0ce87168bb477253646bf4734ecb6c5 Mon Sep 17 00:00:00 2001 From: Nicolas Franck Date: Tue, 6 Apr 2021 10:08:16 +0200 Subject: [PATCH 10/43] redirect edit page of notification after successfull creation/update --- app/controllers/super_admin/notifications_controller.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/controllers/super_admin/notifications_controller.rb b/app/controllers/super_admin/notifications_controller.rb index 4a5a3ba159..fead61996d 100644 --- a/app/controllers/super_admin/notifications_controller.rb +++ b/app/controllers/super_admin/notifications_controller.rb @@ -36,7 +36,7 @@ def create @notification.notification_type = "global" if @notification.save flash.now[:notice] = success_message(@notification, _("created")) - render :edit + redirect_to edit_super_admin_notification_path(@notification) else flash.now[:alert] = failure_message(@notification, _("create")) render :new @@ -49,6 +49,7 @@ def update authorize(Notification) if @notification.update(notification_params) flash.now[:notice] = success_message(@notification, _("updated")) + return redirect_to edit_super_admin_notification_path(@notification) else flash.now[:alert] = failure_message(@notification, _("update")) end From e60de0f45990dc934ed104ef0bc62fc45c88e1ea Mon Sep 17 00:00:00 2001 From: Nicolas Franck Date: Thu, 15 Apr 2021 17:47:45 +0200 Subject: [PATCH 11/43] roles_controller crashes when email is not supplied --- app/controllers/roles_controller.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/controllers/roles_controller.rb b/app/controllers/roles_controller.rb index 8e6fe7aa57..2f93b993aa 100644 --- a/app/controllers/roles_controller.rb +++ b/app/controllers/roles_controller.rb @@ -17,7 +17,10 @@ def create authorize @role message = "" - if role_params[:user].present? && plan.present? + if role_params[:user].present? && + role_params[:user].key?(:email) && + role_params[:user][:email].present? && plan.present? + if @role.plan.owner.present? && @role.plan.owner.email == role_params[:user][:email] # rubocop:disable Layout/LineLength flash[:notice] = _("Cannot share plan with %{email} since that email matches with the owner of the plan.") % { From 63166004a2c482a14b291be452bac743d8742c48 Mon Sep 17 00:00:00 2001 From: Nicolas Franck Date: Thu, 15 Apr 2021 17:48:08 +0200 Subject: [PATCH 12/43] aria-required should also be required --- app/views/plans/_share_form.html.erb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/views/plans/_share_form.html.erb b/app/views/plans/_share_form.html.erb index 6fdfb2c748..ba5c7a5c0e 100644 --- a/app/views/plans/_share_form.html.erb +++ b/app/views/plans/_share_form.html.erb @@ -108,6 +108,7 @@ <%= email_tooltip %> <%= user.email_field :email, class: "form-control", title: email_tooltip, aria: { required: true }, + required: true, date: { toggle: "tooltip", html: true } %> <% end %> @@ -122,7 +123,7 @@
    <%= f.label :administrator_access do %> - <%= f.radio_button :access, administrator.access, id: "role_administrator_access", "aria-required": true %> + <%= f.radio_button :access, administrator.access, id: "role_administrator_access", "aria-required": true, required: true %> <%= _('Co-owner') %> <% end %>
    From 0cbc81f868ee2b794111c1cf38c114f98bcfd15b Mon Sep 17 00:00:00 2001 From: briri Date: Mon, 26 Apr 2021 10:35:45 -0700 Subject: [PATCH 13/43] fix rubocop and eslinter issues --- app/javascript/src/orgs/adminEdit.js | 2 +- app/presenters/super_admin/orgs/merge_presenter.rb | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/javascript/src/orgs/adminEdit.js b/app/javascript/src/orgs/adminEdit.js index c1b2240db0..ac27b03499 100644 --- a/app/javascript/src/orgs/adminEdit.js +++ b/app/javascript/src/orgs/adminEdit.js @@ -58,5 +58,5 @@ $(() => { }); initAutocomplete('#org-merge-controls .autocomplete'); - scrubOrgSelectionParamsOnSubmit('form.edit_org') + scrubOrgSelectionParamsOnSubmit('form.edit_org'); }); diff --git a/app/presenters/super_admin/orgs/merge_presenter.rb b/app/presenters/super_admin/orgs/merge_presenter.rb index 2ac24beb01..e3bc57cf96 100644 --- a/app/presenters/super_admin/orgs/merge_presenter.rb +++ b/app/presenters/super_admin/orgs/merge_presenter.rb @@ -33,6 +33,7 @@ def initialize(from_org:, to_org:) private + # rubocop:disable Metrics/AbcSize def prepare_org(org:) return {} unless org.present? && org.is_a?(Org) @@ -52,6 +53,7 @@ def prepare_org(org:) users: org.users.sort { |a, b| a.email <=> b.email } } end + # rubocop:enable Metrics/AbcSize def prepare_mergeables return {} unless @from_org_entries.any? && @to_org_entries.any? From a740764828475eed0780da828ba72fda5443ba1d Mon Sep 17 00:00:00 2001 From: briri Date: Mon, 26 Apr 2021 13:29:58 -0700 Subject: [PATCH 14/43] bumped caches for gihub actions --- .github/workflows/mysql.yml | 4 ++-- .github/workflows/postgres.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/mysql.yml b/.github/workflows/mysql.yml index 11d2a19bd6..740bd53d1f 100644 --- a/.github/workflows/mysql.yml +++ b/.github/workflows/mysql.yml @@ -42,7 +42,7 @@ jobs: # Try to retrieve the gems from the cache - name: 'Cache Gems' - uses: actions/cache@v1 + uses: actions/cache@v2.1.5 with: path: vendor/bundle key: ${{ runner.os }}-gem-${{ hashFiles('**/Gemfile.lock') }} @@ -62,7 +62,7 @@ jobs: # Try to retrieve the yarn JS dependencies from the cache - name: 'Cache Yarn Packages' - uses: actions/cache@v1 + uses: actions/cache@v2.1.5 with: path: node_modules/ key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} diff --git a/.github/workflows/postgres.yml b/.github/workflows/postgres.yml index 29454be8a5..5681a97364 100644 --- a/.github/workflows/postgres.yml +++ b/.github/workflows/postgres.yml @@ -59,7 +59,7 @@ jobs: # Try to retrieve the gems from the cache - name: 'Cache Gems' - uses: actions/cache@v1 + uses: actions/cache@v2.1.5 with: path: vendor/bundle key: ${{ runner.os }}-gem-${{ hashFiles('**/Gemfile.lock') }} @@ -79,7 +79,7 @@ jobs: # Try to retrieve the yarn JS dependencies from the cache - name: 'Cache Yarn Packages' - uses: actions/cache@v1 + uses: actions/cache@v2.1.5 with: path: node_modules/ key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} From 8e7fcce6d8832fb9edc0b8cb66171771a61c2afd Mon Sep 17 00:00:00 2001 From: briri Date: Mon, 26 Apr 2021 14:53:30 -0700 Subject: [PATCH 15/43] fix for failing test --- spec/features/super_admins/merge_org_spec.rb | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/spec/features/super_admins/merge_org_spec.rb b/spec/features/super_admins/merge_org_spec.rb index e9fed320cf..3f2558b88a 100644 --- a/spec/features/super_admins/merge_org_spec.rb +++ b/spec/features/super_admins/merge_org_spec.rb @@ -33,6 +33,11 @@ click_link "Admin" sleep(0.5) click_link "Organisations" + + fill_in(:search, with: @from_org.name) + click_button "Search" + sleep(0.5) + first("#org-#{@from_org.id}-actions").click first("a[href=\"/org/admin/#{@from_org.id}/admin_edit\"]").click From 7c914bf0cb784bdb11d7727e5e99393e9417e647 Mon Sep 17 00:00:00 2001 From: briri Date: Mon, 26 Apr 2021 15:15:25 -0700 Subject: [PATCH 16/43] fix for failing test --- spec/presenters/super_admin/orgs/merge_presenter_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/presenters/super_admin/orgs/merge_presenter_spec.rb b/spec/presenters/super_admin/orgs/merge_presenter_spec.rb index b1265e5076..ebc6491aa7 100644 --- a/spec/presenters/super_admin/orgs/merge_presenter_spec.rb +++ b/spec/presenters/super_admin/orgs/merge_presenter_spec.rb @@ -18,7 +18,7 @@ contact_name: Faker::Music::PearlJam.musician, contact_email: Faker::Internet.email, links: { org: { foo: "bar" } }, - region: create(:region), + region: create(:region, name: Faker::Music::PearlJam.song), language: create(:language), token_permission_types: [@tpt]) create(:annotation, org: @from_org) From fe644e5628e89f306397119ad1be7373b51b5e34 Mon Sep 17 00:00:00 2001 From: Nicolas Franck Date: Wed, 12 May 2021 10:59:46 +0200 Subject: [PATCH 17/43] run application within RAILS_RELATIVE_URL_ROOT --- config.ru | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/config.ru b/config.ru index 1315d135c3..08dee95cd3 100644 --- a/config.ru +++ b/config.ru @@ -3,4 +3,6 @@ # This file is used by Rack-based servers to start the application. require File.expand_path(File.dirname(__FILE__) + "/config/environment") -run DMPRoadmap::Application +map ENV["RAILS_RELATIVE_URL_ROOT"] || "/" do + run DMPRoadmap::Application +end From 9c96cbf1e6190eddb9e0b6760a89c01abbce31df Mon Sep 17 00:00:00 2001 From: briri Date: Tue, 15 Jun 2021 08:26:11 -0700 Subject: [PATCH 18/43] fixed issue where users who create an account rather than accept an invitation lose their plan share --- app/controllers/registrations_controller.rb | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 0773f8b4a5..298525d6b1 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -66,12 +66,12 @@ def create existing_user = User.where_case_insensitive("email", sign_up_params[:email]).first if existing_user.present? if existing_user.invitation_token.present? && !existing_user.accept_terms? - # Destroys the existing user since the accept terms are nil/false. and they - # have an invitation Note any existing role for that user will be deleted too. - # Added to accommodate issue at: https://github.com/DMPRoadmap/roadmap/issues/322 - # when invited user creates an account outside the invite workflow + # If the user is creating an account but they have an outstanding invitation, remember + # any plans that were shared with the invitee so we can attach them to the new User record + shared_plans = existing_user.roles + .select(&:active?) + .map { |role| { plan_id: role.plan_id, access: role.access } } existing_user.destroy - else redirect_to after_sign_up_error_path_for(resource), alert: _("That email address is already registered.") @@ -88,6 +88,17 @@ def create build_resource(attrs) + # If the user is creating an account but they have an outstanding invitation, attach the shared + # plan(s) to their new User record + if shared_plans.present? && shared_plans.any? + shared_plans.each do |role_hash| + plan = Plan.find_by(id: role_hash[:plan_id]) + next unless plan.present? + + Role.create(plan: plan, user: resource, access: role_hash[:access], active: true) + end + end + # Determine if reCAPTCHA is enabled and if so verify it use_recaptcha = Rails.configuration.x.application.use_recaptcha || false if (!use_recaptcha || verify_recaptcha(model: resource)) && resource.save From 487c7c8f39f61d7c43a92eab38b1d18394b039ec Mon Sep 17 00:00:00 2001 From: Nicolas Franck Date: Thu, 24 Jun 2021 12:26:19 +0200 Subject: [PATCH 19/43] only include active roles in plan share tab; don't take inactive roles into account on creation --- app/controllers/plans_controller.rb | 4 ++-- app/controllers/roles_controller.rb | 6 +++++- app/views/plans/_share_form.html.erb | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/app/controllers/plans_controller.rb b/app/controllers/plans_controller.rb index 9d4ac4761c..e6061c984b 100644 --- a/app/controllers/plans_controller.rb +++ b/app/controllers/plans_controller.rb @@ -302,7 +302,7 @@ def share @plan = Plan.find(params[:id]) if @plan.present? authorize @plan - @plan_roles = @plan.roles + @plan_roles = @plan.roles.where(active: true) else redirect_to(plans_path) end @@ -315,7 +315,7 @@ def request_feedback @plan = Plan.find(params[:id]) if @plan.present? authorize @plan - @plan_roles = @plan.roles + @plan_roles = @plan.roles.where(active: true) else redirect_to(plans_path) end diff --git a/app/controllers/roles_controller.rb b/app/controllers/roles_controller.rb index 2f93b993aa..58e583eb65 100644 --- a/app/controllers/roles_controller.rb +++ b/app/controllers/roles_controller.rb @@ -29,7 +29,11 @@ def create # rubocop:enable Layout/LineLength else user = User.where_case_insensitive("email", role_params[:user][:email]).first - if Role.find_by(plan: @role.plan, user: user) # role already exists + if user.present? && + Role.where(plan: @role.plan, user: user, active: true) + .count + .positive? # role already exists + flash[:notice] = _("Plan is already shared with %{email}.") % { email: role_params[:user][:email] } diff --git a/app/views/plans/_share_form.html.erb b/app/views/plans/_share_form.html.erb index ba5c7a5c0e..f9ec141049 100644 --- a/app/views/plans/_share_form.html.erb +++ b/app/views/plans/_share_form.html.erb @@ -41,7 +41,7 @@

    <%= _('Manage collaborators')%>

    <%= _('Invite specific people to read, edit, or administer your plan. Invitees will receive an email notification that they have access to this plan.') %>

    -<% if @plan.roles.any? then %> +<% if @plan_roles.any? then %> From 327eb05a0d521ec8eee3cc2209813921599caf73 Mon Sep 17 00:00:00 2001 From: briri Date: Fri, 25 Jun 2021 10:48:39 -0700 Subject: [PATCH 20/43] updated paginable logic to allow non-remote refresh of tables during search/sort/page --- app/controllers/concerns/paginable.rb | 14 +++++++---- app/controllers/paginable/plans_controller.rb | 10 ++++---- .../paginable/templates_controller.rb | 16 ++++--------- app/controllers/public_pages_controller.rb | 23 +++++++++++++++---- app/views/contributors/index.html.erb | 1 + app/views/guidances/admin_index.html.erb | 2 ++ app/views/layouts/_paginable.html.erb | 18 +++++++-------- app/views/org_admin/plans/index.html.erb | 1 + .../org_admin/templates/history.html.erb | 1 + app/views/org_admin/templates/index.html.erb | 1 + app/views/org_admin/users/plans.html.erb | 1 + app/views/public_pages/plan_index.html.erb | 2 ++ .../public_pages/template_index.html.erb | 3 ++- app/views/shared/_search.html.erb | 2 +- .../super_admin/api_clients/index.html.erb | 1 + .../super_admin/notifications/index.html.erb | 1 + app/views/super_admin/orgs/index.html.erb | 3 ++- app/views/super_admin/themes/index.html.erb | 1 + app/views/users/admin_index.html.erb | 1 + 19 files changed, 64 insertions(+), 38 deletions(-) diff --git a/app/controllers/concerns/paginable.rb b/app/controllers/concerns/paginable.rb index 738d6af215..fd7a88d4cd 100644 --- a/app/controllers/concerns/paginable.rb +++ b/app/controllers/concerns/paginable.rb @@ -37,7 +37,7 @@ module Paginable # one approach to just include everything in the double splat `**options` param # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/ParameterLists - def paginable_renderise(partial: nil, controller: nil, action: nil, + def paginable_renderise(partial: nil, template: nil, controller: nil, action: nil, path_params: {}, query_params: {}, scope: nil, locals: {}, **options) unless scope.is_a?(ActiveRecord::Relation) @@ -50,6 +50,7 @@ def paginable_renderise(partial: nil, controller: nil, action: nil, # Default options @paginable_options = {}.merge(options) @paginable_options[:view_all] = options.fetch(:view_all, true) + @paginable_options[:remote] = options.fetch(:remote, true) # Assignment for paginable_params based on arguments passed to the method @args = paginable_params.to_h @args[:controller] = controller if controller @@ -72,14 +73,19 @@ def paginable_renderise(partial: nil, controller: nil, action: nil, locals = locals.merge( scope: @refined_scope, paginable_params: @args, - search_term: @args[:search] + search_term: @args[:search], + remote: @paginable_options[:remote] ) # If this was an ajax call then render as JSON if options[:format] == :json render json: { html: render_to_string(layout: "/layouts/paginable", partial: partial, locals: locals) } else - render(layout: "/layouts/paginable", partial: partial, locals: locals) + if partial.present? + render(layout: "/layouts/paginable", partial: partial, locals: locals) + else + render(template: template, locals: locals) + end end end end @@ -104,7 +110,7 @@ def paginable_sort_link(sort_field) sort_link_name(sort_field), sort_link_url(sort_field), class: "paginable-action", - data: { remote: true }, + data: { remote: @paginable_options[:remote] }, aria: { label: sort_field } ) end diff --git a/app/controllers/paginable/plans_controller.rb b/app/controllers/paginable/plans_controller.rb index e143caea2e..1f3bb6b9d5 100644 --- a/app/controllers/paginable/plans_controller.rb +++ b/app/controllers/paginable/plans_controller.rb @@ -34,12 +34,10 @@ def organisationally_or_publicly_visible # GET /paginable/plans/publicly_visible/:page def publicly_visible - paginable_renderise( - partial: "publicly_visible", - scope: Plan.publicly_visible.includes(:template), - query_params: { sort_field: "plans.updated_at", sort_direction: :desc }, - format: :json - ) + # We want the pagination/sort/search to be retained in the URL so redirect instead + # of processing this as a JSON + paginable_params = params.permit(:page, :search, :sort_field, :sort_direction) + redirect_to public_plans_path(paginable_params.to_h) end # GET /paginable/plans/org_admin/:page diff --git a/app/controllers/paginable/templates_controller.rb b/app/controllers/paginable/templates_controller.rb index 0457842868..680e7eb8ca 100644 --- a/app/controllers/paginable/templates_controller.rb +++ b/app/controllers/paginable/templates_controller.rb @@ -85,18 +85,10 @@ def customisable # GET /paginable/templates/publicly_visible/:page (AJAX) # ----------------------------------------------------- def publicly_visible - templates = Template.live(Template.families(Org.funder.pluck(:id)).pluck(:family_id)) - .publicly_visible.pluck(:id) << - Template.where(is_default: true).unarchived.published.pluck(:id) - paginable_renderise( - partial: "publicly_visible", - scope: Template.joins(:org) - .includes(:org) - .where(id: templates.uniq.flatten) - .published, - query_params: { sort_field: "templates.title", sort_direction: :asc }, - format: :json - ) + # We want the pagination/sort/search to be retained in the URL so redirect instead + # of processing this as a JSON + paginable_params = params.permit(:page, :search, :sort_field, :sort_direction) + redirect_to public_templates_path(paginable_params.to_h) end # GET /paginable/templates/:id/history/:page (AJAX) diff --git a/app/controllers/public_pages_controller.rb b/app/controllers/public_pages_controller.rb index 2ab686e2a8..7fc21d4bf1 100644 --- a/app/controllers/public_pages_controller.rb +++ b/app/controllers/public_pages_controller.rb @@ -5,12 +5,19 @@ class PublicPagesController < ApplicationController # GET template_index # ----------------------------------------------------- def template_index + @templates_query_params = { + page: paginable_params.fetch(:page, 1), + search: paginable_params.fetch(:search, ""), + sort_field: paginable_params.fetch(:sort_field, "templates.title"), + sort_direction: paginable_params.fetch(:sort_direction, "asc") + } + templates = Template.live(Template.families(Org.funder.pluck(:id)).pluck(:family_id)) .publicly_visible.pluck(:id) << Template.where(is_default: true).unarchived.published.pluck(:id) @templates = Template.includes(:org) .where(id: templates.uniq.flatten) - .unarchived.published.order(title: :asc).page(1) + .unarchived.published end # GET template_export/:id @@ -79,13 +86,21 @@ def template_export # GET /plans_index # ------------------------------------------------------------------------------------ def plan_index - @plans = Plan.publicly_visible.includes(:template).page(1) + @plans = Plan.publicly_visible.includes(:template) render "plan_index", locals: { query_params: { - sort_field: "plans.updated_at", - sort_direction: "desc" + page: paginable_params.fetch(:page, 1), + search: paginable_params.fetch(:search, ""), + sort_field: paginable_params.fetch(:sort_field, "plans.updated_at"), + sort_direction: paginable_params.fetch(:sort_direction, "desc") } } end + private + + def paginable_params + params.permit(:page, :search, :sort_field, :sort_direction) + end + end diff --git a/app/views/contributors/index.html.erb b/app/views/contributors/index.html.erb index c41becec56..ddd3911313 100644 --- a/app/views/contributors/index.html.erb +++ b/app/views/contributors/index.html.erb @@ -19,6 +19,7 @@ <%= paginable_renderise partial: "/paginable/contributors/index", controller: "paginable/contributors", action: "index", + remote: true, scope: @contributors, locals: { plan: @plan }, query_params: { diff --git a/app/views/guidances/admin_index.html.erb b/app/views/guidances/admin_index.html.erb index 15a7eb352d..eb52bb0b74 100644 --- a/app/views/guidances/admin_index.html.erb +++ b/app/views/guidances/admin_index.html.erb @@ -19,6 +19,7 @@ partial: '/paginable/guidance_groups/index', controller: 'paginable/guidance_groups', action: 'index', + remote: true, scope: @guidance_groups, query_params: { sort_field: 'guidance_groups.name', sort_direction: :asc }) %>
    @@ -39,6 +40,7 @@ partial: '/paginable/guidances/index', controller: 'paginable/guidances', action: 'index', + remote: true, scope: @guidances, query_params: { sort_field: 'guidances.text', sort_direction: :asc }) %> diff --git a/app/views/layouts/_paginable.html.erb b/app/views/layouts/_paginable.html.erb index 7b30ca0074..5afb3f7c56 100644 --- a/app/views/layouts/_paginable.html.erb +++ b/app/views/layouts/_paginable.html.erb @@ -1,6 +1,6 @@ <% # Custom layout to be included on any view that needs pagination - # locals: { scope, search_term, checkbox_status } + # locals: { remote, scope, search_term, checkbox_status } %> <% total = paginable? ? scope.total_count : scope.length %>
    @@ -8,7 +8,7 @@
    <%= render(partial: '/shared/search', - locals: { search_term: search_term }) if searchable? || @filter_admin || total > Kaminari.config.default_per_page %> + locals: { remote: remote, search_term: search_term }) if searchable? || @filter_admin || total > Kaminari.config.default_per_page %>
    @@ -31,28 +31,28 @@ <% if searchable? %>
      <% if paginable? %> -
    • <%= link_to(_('View all search results'), paginable_base_url('ALL'), { 'data-remote': true, class: 'paginable-action' }) %>
    • +
    • <%= link_to(_('View all search results'), paginable_base_url('ALL'), { 'data-remote': remote, class: 'paginable-action' }) %>
    • <% else %> - <%= link_to(_('View less search results'), paginable_base_url(1), { 'data-remote': true, class: 'paginable-action' }) %> + <%= link_to(_('View less search results'), paginable_base_url(1), { 'data-remote': remote, class: 'paginable-action' }) %> <% end %> -
    • <%= link_to(_('Clear search results'), paginable_base_url(1), { 'data-remote': true, class: 'paginable-action' }) %>
    • +
    • <%= link_to(_('Clear search results'), paginable_base_url(1), { 'data-remote': remote, class: 'paginable-action' }) %>
    <% else %> <% if paginable? %> - <%= link_to(_('View all'), paginable_base_url('ALL'), { 'data-remote': true, class: 'paginable-action' }) if @paginable_options[:view_all] %> + <%= link_to(_('View all'), paginable_base_url('ALL'), { 'data-remote': remote, class: 'paginable-action' }) if @paginable_options[:view_all] %> <% else %> - <%= link_to(_('View less'), paginable_base_url(1), { 'data-remote': true, class: 'paginable-action' }) %> + <%= link_to(_('View less'), paginable_base_url(1), { 'data-remote': remote, class: 'paginable-action' }) %> <% end %> <% end %> <% else %> <% if searchable? || @filter_admin %> - <%= link_to(_('Clear search results'), paginable_base_url(1), { 'data-remote': true, class: 'paginable-action' }) %> + <%= link_to(_('Clear search results'), paginable_base_url(1), { 'data-remote': remote, class: 'paginable-action' }) %> <% end %> <% end %>
    <% if paginable? %> - <%= paginate(scope, params: paginable_params, remote: true) %> + <%= paginate(scope, params: paginable_params, remote: remote) %> <% end %>
    diff --git a/app/views/org_admin/plans/index.html.erb b/app/views/org_admin/plans/index.html.erb index db63d6505b..2b16861c5d 100644 --- a/app/views/org_admin/plans/index.html.erb +++ b/app/views/org_admin/plans/index.html.erb @@ -48,6 +48,7 @@ partial: '/paginable/plans/org_admin', controller: 'paginable/plans', action: 'org_admin', + remote: true, scope: @plans, view_all: !current_user.can_super_admin?, query_params: { sort_field: 'plans.updated_at', sort_direction: :desc }) %> diff --git a/app/views/org_admin/templates/history.html.erb b/app/views/org_admin/templates/history.html.erb index 9e42f43290..da311ea07b 100644 --- a/app/views/org_admin/templates/history.html.erb +++ b/app/views/org_admin/templates/history.html.erb @@ -20,6 +20,7 @@ partial: '/paginable/templates/history', controller: 'paginable/templates', action: 'history', + remote: true, query_params: query_params, scope: templates, locals: local_assigns ) %> diff --git a/app/views/org_admin/templates/index.html.erb b/app/views/org_admin/templates/index.html.erb index 6ca181f3d3..9de826dfe5 100644 --- a/app/views/org_admin/templates/index.html.erb +++ b/app/views/org_admin/templates/index.html.erb @@ -105,6 +105,7 @@ partial: "paginable/templates/#{action_name}", controller: 'paginable/templates', action: action_name, + remote: true, scope: @templates, query_params: @query_params, locals: { customizations: @customizations }) %> diff --git a/app/views/org_admin/users/plans.html.erb b/app/views/org_admin/users/plans.html.erb index 93ce5d5f6b..25f05671a3 100644 --- a/app/views/org_admin/users/plans.html.erb +++ b/app/views/org_admin/users/plans.html.erb @@ -10,6 +10,7 @@ partial: '/paginable/plans/org_admin_other_user', controller: 'paginable/plans', action: 'org_admin_other_user', + remote: true, scope: @plans, query_params: { sort_field: 'plans.updated_at', sort_direction: 'desc' }) %> diff --git a/app/views/public_pages/plan_index.html.erb b/app/views/public_pages/plan_index.html.erb index 6d91a3c698..5d5af35f21 100644 --- a/app/views/public_pages/plan_index.html.erb +++ b/app/views/public_pages/plan_index.html.erb @@ -22,6 +22,8 @@ partial: '/paginable/plans/publicly_visible', controller: 'paginable/plans', action: 'publicly_visible', + remote: false, + path_params: @args || {}, query_params: query_params, scope: @plans) %> <% end %> diff --git a/app/views/public_pages/template_index.html.erb b/app/views/public_pages/template_index.html.erb index 03aeb6f820..014d5db364 100644 --- a/app/views/public_pages/template_index.html.erb +++ b/app/views/public_pages/template_index.html.erb @@ -16,8 +16,9 @@ partial: '/paginable/templates/publicly_visible', controller: 'paginable/templates', action: 'publicly_visible', + remote: false, scope: @templates, - query_params: { sort_field: 'templates.title', sort_direction: :asc }) %> + query_params: @templates_query_params) %> <% end %> \ No newline at end of file diff --git a/app/views/shared/_search.html.erb b/app/views/shared/_search.html.erb index 01972d4fc2..7fb8c234c2 100644 --- a/app/views/shared/_search.html.erb +++ b/app/views/shared/_search.html.erb @@ -1,6 +1,6 @@ <%# locals: { search_term } %> -<%= form_tag(paginable_base_url(1), method: :get, remote: true, class: 'form-inline paginable-action') do %> +<%= form_tag(paginable_base_url(1), method: :get, remote: remote, class: 'form-inline paginable-action') do %>
    diff --git a/app/views/super_admin/api_clients/index.html.erb b/app/views/super_admin/api_clients/index.html.erb index ed11626bf8..01801c75d0 100644 --- a/app/views/super_admin/api_clients/index.html.erb +++ b/app/views/super_admin/api_clients/index.html.erb @@ -17,6 +17,7 @@ partial: '/paginable/api_clients/index', controller: 'paginable/api_clients', action: 'index', + remote: true, scope: @api_clients, query_params: { sort_field: 'api_clients.name', sort_direction: :asc }) %>
    diff --git a/app/views/super_admin/notifications/index.html.erb b/app/views/super_admin/notifications/index.html.erb index f55c619940..f0b174a052 100644 --- a/app/views/super_admin/notifications/index.html.erb +++ b/app/views/super_admin/notifications/index.html.erb @@ -14,6 +14,7 @@ partial: '/paginable/notifications/index', controller: 'paginable/notifications', action: 'index', + remote: true, scope: notifications, query_params: { sort_field: 'notifications.starts_at', sort_direction: 'desc' }) %> diff --git a/app/views/super_admin/orgs/index.html.erb b/app/views/super_admin/orgs/index.html.erb index ef42981c60..dd6d521cbc 100644 --- a/app/views/super_admin/orgs/index.html.erb +++ b/app/views/super_admin/orgs/index.html.erb @@ -4,7 +4,7 @@

    <%= _('Organisations') %> - <%= _('Create Organisation') %>

    @@ -16,6 +16,7 @@ partial: '/paginable/orgs/index', controller: 'paginable/orgs', action: 'index', + remote: true, scope: orgs, query_params: { sort_field: 'orgs.name', sort_direction: :asc }) %>
    diff --git a/app/views/super_admin/themes/index.html.erb b/app/views/super_admin/themes/index.html.erb index ebc567d861..77557e6013 100644 --- a/app/views/super_admin/themes/index.html.erb +++ b/app/views/super_admin/themes/index.html.erb @@ -14,6 +14,7 @@ partial: '/paginable/themes/index', controller: 'paginable/themes', action: 'index', + remote: true, scope: themes, query_params: { sort_field: 'themes.title', sort_direction: :asc }) %> diff --git a/app/views/users/admin_index.html.erb b/app/views/users/admin_index.html.erb index f14fc59f84..698f261eb6 100644 --- a/app/views/users/admin_index.html.erb +++ b/app/views/users/admin_index.html.erb @@ -26,6 +26,7 @@ partial: '/paginable/users/index', controller: 'paginable/users', action: 'index', + remote: true, scope: @users, view_all: !current_user.can_super_admin?) %>
    From f2fb8ee2fbe1252875f4e81aaa9be7c5d0f6b8d2 Mon Sep 17 00:00:00 2001 From: briri Date: Fri, 25 Jun 2021 15:04:41 -0700 Subject: [PATCH 21/43] added spinner that gets displayed on UJS/Ajax calls: --- app/assets/images/spinner.gif | Bin 0 -> 8425 bytes app/assets/stylesheets/blocks/_spinner.scss | 5 +++++ app/javascript/packs/application.js | 1 + app/javascript/src/utils/spinner.js | 19 +++++++++++++++++++ app/views/layouts/application.html.erb | 8 ++++++++ 5 files changed, 33 insertions(+) create mode 100644 app/assets/images/spinner.gif create mode 100644 app/assets/stylesheets/blocks/_spinner.scss create mode 100644 app/javascript/src/utils/spinner.js diff --git a/app/assets/images/spinner.gif b/app/assets/images/spinner.gif new file mode 100644 index 0000000000000000000000000000000000000000..f292d5972ccb2582b135338ef41f6a1954e0862f GIT binary patch literal 8425 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 RyQ^Cnf1t~B|FcDE{SRj=c>w?b literal 0 HcmV?d00001 diff --git a/app/assets/stylesheets/blocks/_spinner.scss b/app/assets/stylesheets/blocks/_spinner.scss new file mode 100644 index 0000000000..c564e9b2d0 --- /dev/null +++ b/app/assets/stylesheets/blocks/_spinner.scss @@ -0,0 +1,5 @@ +.spinner-border { + position: fixed; + top: 48%; + left: 43%; +} \ No newline at end of file diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js index 23254c1822..86057c3b8f 100644 --- a/app/javascript/packs/application.js +++ b/app/javascript/packs/application.js @@ -31,6 +31,7 @@ import '../src/utils/paginable'; import '../src/utils/panelHeading'; import '../src/utils/popoverHelper'; import '../src/utils/requiredField'; +import '../src/utils/spinner'; import '../src/utils/tabHelper'; import '../src/utils/tooltipHelper'; diff --git a/app/javascript/src/utils/spinner.js b/app/javascript/src/utils/spinner.js new file mode 100644 index 0000000000..9123f825af --- /dev/null +++ b/app/javascript/src/utils/spinner.js @@ -0,0 +1,19 @@ +// Will display a spinner at the start of any UJS/Ajax call and then hide it after the +// controller responds. +$(() => { + const toggleSpinner = () => { + const spinnerBlock = $('.spinner-border'); + + if (spinnerBlock.length > 0) { + if (spinnerBlock.hasClass('hidden')) { + spinnerBlock.removeClass('hidden'); + } else { + spinnerBlock.addClass('hidden'); + } + } + }; + + $('body').on('ajax:send', toggleSpinner); + $('body').on('ajax:success', toggleSpinner); + $('body').on('ajax:error', toggleSpinner); +}); diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 4ec9fe8834..14d978227f 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -94,6 +94,14 @@
    <%= render "layouts/notifications", notifications: Notification.active_per_user(current_user) %> <%= yield %> + + <%# Generic UJS/Ajax spinner. Bootstrap 5+ has built in spinner for this class %> + + From 1f099fcad6493bddf00efd2c335e7d272ab268d7 Mon Sep 17 00:00:00 2001 From: briri Date: Fri, 25 Jun 2021 15:14:36 -0700 Subject: [PATCH 22/43] fixed rubocop --- app/controllers/concerns/paginable.rb | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/app/controllers/concerns/paginable.rb b/app/controllers/concerns/paginable.rb index fd7a88d4cd..aea376ed12 100644 --- a/app/controllers/concerns/paginable.rb +++ b/app/controllers/concerns/paginable.rb @@ -80,12 +80,10 @@ def paginable_renderise(partial: nil, template: nil, controller: nil, action: ni if options[:format] == :json render json: { html: render_to_string(layout: "/layouts/paginable", partial: partial, locals: locals) } + elsif partial.present? + render(layout: "/layouts/paginable", partial: partial, locals: locals) else - if partial.present? - render(layout: "/layouts/paginable", partial: partial, locals: locals) - else - render(template: template, locals: locals) - end + render(template: template, locals: locals) end end end From c5045833f712b82316523a8310917c1470c28b73 Mon Sep 17 00:00:00 2001 From: briri Date: Fri, 25 Jun 2021 15:21:22 -0700 Subject: [PATCH 23/43] fixed rubocop --- app/controllers/public_pages_controller.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/controllers/public_pages_controller.rb b/app/controllers/public_pages_controller.rb index 7fc21d4bf1..d6dd911315 100644 --- a/app/controllers/public_pages_controller.rb +++ b/app/controllers/public_pages_controller.rb @@ -4,6 +4,7 @@ class PublicPagesController < ApplicationController # GET template_index # ----------------------------------------------------- + # rubocop:disable Metrics/AbcSize def template_index @templates_query_params = { page: paginable_params.fetch(:page, 1), @@ -19,6 +20,7 @@ def template_index .where(id: templates.uniq.flatten) .unarchived.published end + # rubocop:enable Metrics/AbcSize # GET template_export/:id # ----------------------------------------------------- From 081aad2b0888e2557047766746153ebf7f1322d1 Mon Sep 17 00:00:00 2001 From: briri Date: Tue, 29 Jun 2021 07:59:15 -0700 Subject: [PATCH 24/43] add 'other' role to contributors page --- app/models/contributor.rb | 3 ++- app/presenters/api/v1/contributor_presenter.rb | 1 + app/presenters/contributor_presenter.rb | 4 +++- app/views/contributors/_form.html.erb | 2 +- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/app/models/contributor.rb b/app/models/contributor.rb index 0c103c8392..f177935ad9 100644 --- a/app/models/contributor.rb +++ b/app/models/contributor.rb @@ -67,6 +67,7 @@ class Contributor < ApplicationRecord has_flags 1 => :data_curation, 2 => :investigation, 3 => :project_administration, + 4 => :other, column: "roles" # ========== @@ -91,7 +92,7 @@ class << self # returns the default role def default_role - "investigation" + "other" end end diff --git a/app/presenters/api/v1/contributor_presenter.rb b/app/presenters/api/v1/contributor_presenter.rb index 0f4c22c61e..6970433bd7 100644 --- a/app/presenters/api/v1/contributor_presenter.rb +++ b/app/presenters/api/v1/contributor_presenter.rb @@ -11,6 +11,7 @@ class << self # Convert the specified role into a CRediT Taxonomy URL def role_as_uri(role:) return nil unless role.present? + return "other" if role.to_s.downcase == "other" "#{Contributor::ONTOLOGY_BASE_URL}/#{role.to_s.downcase.gsub('_', '-')}" end diff --git a/app/presenters/contributor_presenter.rb b/app/presenters/contributor_presenter.rb index 73d721fa04..91ce18bab2 100644 --- a/app/presenters/contributor_presenter.rb +++ b/app/presenters/contributor_presenter.rb @@ -43,8 +43,10 @@ def role_symbol_to_string(symbol:) "Data Manager" when :project_administration "Project Administrator" - else + when :investigation "Principal Investigator" + else + "Other" end end diff --git a/app/views/contributors/_form.html.erb b/app/views/contributors/_form.html.erb index 58269ebedb..d59a64f807 100644 --- a/app/views/contributors/_form.html.erb +++ b/app/views/contributors/_form.html.erb @@ -42,7 +42,7 @@ roles_tooltip = _("Select each role that applies to the contributor.")
    <%= form.label(:phone, _("Phone number"), class: "control-label") %>
    -
    +
    <%= phone_tooltip %> <%= form.phone_field :phone, class: "form-control", title: phone_tooltip, From a812b4270489209fc62e8abcbd74a0a917048a8b Mon Sep 17 00:00:00 2001 From: briri Date: Tue, 29 Jun 2021 08:01:58 -0700 Subject: [PATCH 25/43] fixed layout of roles on contributor form --- app/views/contributors/_form.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/contributors/_form.html.erb b/app/views/contributors/_form.html.erb index d59a64f807..f199b2c90e 100644 --- a/app/views/contributors/_form.html.erb +++ b/app/views/contributors/_form.html.erb @@ -73,7 +73,7 @@ roles_tooltip = _("Select each role that applies to the contributor.") <% roles = ContributorPresenter.roles_for_radio(contributor: contributor) %> <% roles.each do |hash| %> -
    +
    <%= form.check_box hash.keys.first.to_sym, value: hash.values.first, From 4aa1d93d3812da43c6332729aa15c00db90103bc Mon Sep 17 00:00:00 2001 From: briri Date: Tue, 29 Jun 2021 08:08:07 -0700 Subject: [PATCH 26/43] found a better spinner gif and updated to restrict the size of the spinner --- app/assets/images/spinner.gif | Bin 8425 -> 32701 bytes app/assets/stylesheets/blocks/_spinner.scss | 5 +++++ app/views/layouts/application.html.erb | 4 +++- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/app/assets/images/spinner.gif b/app/assets/images/spinner.gif index f292d5972ccb2582b135338ef41f6a1954e0862f..0b3ba6284d73f3aa86128563810e018aa2d22f20 100644 GIT binary patch literal 32701 zcmaglWmHu0`!4)F$uM+x*U*Sa3qz;U9S+?gARsvdLr6&p2uL>yh)BoKEucsW4hVuM zQYu(*{Qb{!ay@IEcl+&Ld#}Ae-|N0^9X)M%1*Z%k19+N_cSlBsdU`tN z=Vwn&4$sbxfBpJ-diwSI_iuZ9JH5T#U%wvA&CTrYZVwI)w6?aatu1YD6FWOQ4iCSq zudhr^P5k-uXJut^Y;5Gu?uJ;(tD{)6(C`S`n1nqgn%X*YyBjIbip*aO!`sa|yShb{Ec$Z@<>sU#FDS;0`4r_Q z>*^@YUllHxA{XBxmv2+At#@sR5(ff4n0#!dpcwsR*mYQQ1gXJ8zKb9ck?>=i^$w_x zs()PwqU{abRt@!LM%aErN-rbkGS?ww`{{OX)-7i*^YF3{w6Q!rpNQ1eRa&_Q&aPGM z3%l7@>{2{UMA+y@y<+`P+I7Ux9$Kk^rk+WQElU%sN%7Ai=bVj zu;!ixDLz#0&m_5S>r>Gx9dy;LI^#Qb0(WnXvVX3cZSFDpaXNaQ{~)ntDr6Dm%j7fF z$L4b*hSX{wNpp)_RbxhTPd}mPaWaDa`yI`2L(TV^sWwh;77duuF|`d&yDxOxYBnZl z!f45!?)IntE#l0EzGPwZIBkF7W1gNnq0+5+6qq1YBj@zQa1}|mR`lU74p5wH+RnGv zm0)C`JWTWuQXt0EtIwW@4`yJ#KRc$NmERfXpjC`cT2tWzjzLHg|3Ogna(T99T>pO9 z6*QJomaxPq<=aAl$N1faP;j;BLMUK?SJwHXb72x4TfhAS z7>^c~Q2Owvxzl#O6QeM|;0B-Kr;ydYhVe1wW!}@X`BBT5Z)`5bpB0@*>RA?7F}aN} ztpeQc_i~gEp(Bhf@L=1Z(QJCSp}toÍ~as2I{P^I;@vo+TWk+_8Vdx{ZMQAXjD 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 RyQ^Cnf1t~B|FcDE{SRj=c>w?b diff --git a/app/assets/stylesheets/blocks/_spinner.scss b/app/assets/stylesheets/blocks/_spinner.scss index c564e9b2d0..a812084c90 100644 --- a/app/assets/stylesheets/blocks/_spinner.scss +++ b/app/assets/stylesheets/blocks/_spinner.scss @@ -2,4 +2,9 @@ position: fixed; top: 48%; left: 43%; +} + +.spinner-border img { + height: 80px; + width: 80px; } \ No newline at end of file diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 14d978227f..079d6e3b34 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -95,7 +95,9 @@ <%= render "layouts/notifications", notifications: Notification.active_per_user(current_user) %> <%= yield %> - <%# Generic UJS/Ajax spinner. Bootstrap 5+ has built in spinner for this class %> + + <%# Generic UJS/Ajax spinner. Bootstrap 5+ has built in spinner for this class, so update when + we upgrade to remove the img tag %>