diff --git a/Gemfile b/Gemfile index ce079fdbaa..06c6fabd24 100644 --- a/Gemfile +++ b/Gemfile @@ -213,6 +213,8 @@ gem 'httparty' # Autoload dotenv in Rails. (https://github.com/bkeepers/dotenv) gem 'dotenv-rails' +gem 'activerecord_json_validator' + # ================================= # # ENVIRONMENT SPECIFIC DEPENDENCIES # # ================================= # diff --git a/Gemfile.lock b/Gemfile.lock index d1a8d1dba1..0fdc757d62 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -33,6 +33,9 @@ GEM activemodel (= 5.2.6.2) activesupport (= 5.2.6.2) arel (>= 9.0) + activerecord_json_validator (2.1.0) + activerecord (>= 4.2.0, < 8) + json_schemer (~> 0.2.18) activestorage (5.2.6.2) actionpack (= 5.2.6.2) activerecord (= 5.2.6.2) @@ -130,6 +133,8 @@ GEM dragonfly-s3_data_store (1.3.0) dragonfly (~> 1.0) fog-aws + ecma-re-validator (0.4.0) + regexp_parser (~> 2.2) erubi (1.10.0) excon (0.91.0) execjs (2.8.1) @@ -207,6 +212,7 @@ GEM guard (~> 2.1) guard-compat (~> 1.1) rspec (>= 2.99.0, < 4.0) + hana (1.3.7) hashdiff (1.0.1) hashie (5.0.0) highline (2.0.3) @@ -223,6 +229,11 @@ GEM actionview (>= 5.0.0) activesupport (>= 5.0.0) json (2.6.1) + json_schemer (0.2.19) + ecma-re-validator (~> 0.3) + hana (~> 1.3) + regexp_parser (~> 2.0) + uri_template (~> 0.7) jwt (2.3.0) kaminari (1.2.2) activesupport (>= 4.1.0) @@ -474,6 +485,7 @@ GEM thread_safe (~> 0.1) unicode-display_width (2.1.0) uniform_notifier (1.14.2) + uri_template (0.7.0) warden (1.2.9) rack (>= 2.0.9) web-console (3.7.0) @@ -515,6 +527,7 @@ PLATFORMS x86_64-linux DEPENDENCIES + activerecord_json_validator annotate annotate_gem api-pagination diff --git a/app/assets/stylesheets/blocks/_modal_search.scss b/app/assets/stylesheets/blocks/_modal_search.scss new file mode 100644 index 0000000000..75e87c828e --- /dev/null +++ b/app/assets/stylesheets/blocks/_modal_search.scss @@ -0,0 +1,75 @@ +.modal-search-block { + border: 1px solid $color-grey; + margin-bottom: 10px; + padding: 10px 5px; +} + +.modal-search .modal-dialog { + /* Make the dialog 80% of the screen height/width */ + width: 80%; + // height: 80%; +} +.modal-search .modal-body { + /* 100% = dialog height, 50px = header (27.5px) + footer (21px) */ + // max-height: calc(80% - 50px); + max-height: 450px; + overflow-y: scroll; +} + +.modal-search-results-pagination { + margin-bottom: 10px; +} + +.modal-search-result { + margin-top: 5px; + padding-bottom: 5px; + + .modal-search-result-label { + font-size: 1.6rem; + font-weight: 500; + } + + .tags > .tag { + display: inline-block; + margin: 5px 2px; + } + .tags .facet { + border: 1px solid $color-blue; + border-radius: 25px; + padding: 2px 5px; + } + + div { + margin-bottom: 5px; + } + + dl { + margin-left: 20px; + + dd { + margin-bottom: 5px; + } + } +} + +.modal-search-results .modal-search-result { + border-bottom: 1px solid $color-grey; +} + +/* the 'Select' button displayed in the modal dialog */ +.modal-search-result .modal-search-result-selector, +.modal-search-result .modal-search-result-unselector { + display: inline-block; + background-color: $color-white; + border-radius: 25px; + padding: 2px 5px; + font-size: 1.3rem; +} +.modal-search-result .modal-search-result-selector { + background-color: $color-green; + color: $color-white; +} +.modal-search-result .modal-search-result-unselector { + border: 1px solid $color-red; + color: $color-red; +} diff --git a/app/assets/stylesheets/variables/_colours.scss b/app/assets/stylesheets/variables/_colours.scss index 17cc9034aa..c10b0e676c 100644 --- a/app/assets/stylesheets/variables/_colours.scss +++ b/app/assets/stylesheets/variables/_colours.scss @@ -5,6 +5,7 @@ $color-black: #000; $color-white: #FFF; $color-red: #b94a48; +$color-green: #4c8d3f; $color-grey: #4F5253; $color-grey-darkest: #222; $color-grey-darker: #333; diff --git a/app/controllers/plan_exports_controller.rb b/app/controllers/plan_exports_controller.rb index 504b307952..41edc38212 100644 --- a/app/controllers/plan_exports_controller.rb +++ b/app/controllers/plan_exports_controller.rb @@ -6,7 +6,8 @@ class PlanExportsController < ApplicationController include ConditionsHelper - # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + # rubocop:disable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity def show @plan = Plan.includes(:answers, { template: { phases: { sections: :questions } } }) .find(params[:plan_id]) @@ -17,6 +18,7 @@ def show @show_sections_questions = export_params[:question_headings].present? @show_unanswered = export_params[:unanswered_questions].present? @show_custom_sections = export_params[:custom_sections].present? + @show_research_outputs = export_params[:research_outputs].present? @public_plan = false elsif publicly_authorized? @@ -25,6 +27,7 @@ def show @show_sections_questions = true @show_unanswered = true @show_custom_sections = true + @show_research_outputs = @plan.research_outputs&.any? || false @public_plan = true else @@ -49,7 +52,8 @@ def show format.json { show_json } end end - # rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + # rubocop:enable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity private @@ -124,8 +128,9 @@ def privately_authorized? end def export_params - params.require(:export).permit(:form, :project_details, :question_headings, - :unanswered_questions, :custom_sections, - :formatting) + params.require(:export) + .permit(:form, :project_details, :question_headings, :unanswered_questions, + :custom_sections, :research_outputs, + formatting: [:font_face, :font_size, { margin: %i[top right bottom left] }]) end end diff --git a/app/controllers/plans_controller.rb b/app/controllers/plans_controller.rb index 08bd798d0f..6025ea3f6a 100644 --- a/app/controllers/plans_controller.rb +++ b/app/controllers/plans_controller.rb @@ -270,9 +270,7 @@ def update funder_attrs[:org_id] = plan_params[:funder][:id] funder = org_from_params(params_in: funder_attrs) @plan.funder_id = funder&.id - attrs.delete(:funder) - - process_grant(grant_params: plan_params[:grant]) + @plan.grant = plan_params[:grant] attrs.delete(:grant) attrs = remove_org_selection_params(params_in: attrs) @@ -537,29 +535,5 @@ def render_phases_edit(plan, phase, guidance_groups) guidance_presenter: GuidancePresenter.new(plan) }) end - - # Update, destroy or add the grant - # rubocop:disable Metrics/AbcSize - # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity - def process_grant(grant_params:) - return false unless grant_params.present? - - grant = @plan.grant - - # delete it if it has been blanked out - if grant_params[:value].blank? && grant.present? - grant.destroy - @plan.grant = nil - elsif grant_params[:value] != grant&.value - if grant.present? - grant.update(value: grant_params[:value]) - elsif grant_params[:value].present? - @plan.grant = Identifier.new(identifier_scheme: nil, identifiable: @plan, - value: grant_params[:value]) - end - end - end - # rubocop:enable Metrics/AbcSize - # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity end # rubocop:enable Metrics/ClassLength diff --git a/app/controllers/research_outputs_controller.rb b/app/controllers/research_outputs_controller.rb new file mode 100644 index 0000000000..c05927b20c --- /dev/null +++ b/app/controllers/research_outputs_controller.rb @@ -0,0 +1,219 @@ +# frozen_string_literal: true + +# Controller to handle CRUD operations for the Research Outputs tab +class ResearchOutputsController < ApplicationController + helper PaginableHelper + + before_action :fetch_plan, except: %i[select_output_type select_license repository_search + metadata_standard_search] + before_action :fetch_research_output, only: %i[edit update destroy] + + after_action :verify_authorized + + # GET /plans/:plan_id/research_outputs + def index + @research_outputs = ResearchOutput.includes(:repositories) + .where(plan_id: @plan.id) + authorize @research_outputs.first || ResearchOutput.new(plan_id: @plan.id) + end + + # GET /plans/:plan_id/research_outputs/new + def new + @research_output = ResearchOutput.new(plan_id: @plan.id, output_type: '') + authorize @research_output + end + + # GET /plans/:plan_id/research_outputs/:id/edit + def edit + authorize @research_output + end + + # POST /plans/:plan_id/research_outputs + def create + args = process_byte_size.merge({ plan_id: @plan.id }) + args = process_nillable_values(args: args) + @research_output = ResearchOutput.new(args) + authorize @research_output + + if @research_output.save + redirect_to plan_research_outputs_path(@plan), + notice: success_message(@research_output, _('added')) + else + flash[:alert] = failure_message(@research_output, _('add')) + render 'research_outputs/new' + end + end + + # PATCH/PUT /plans/:plan_id/research_outputs/:id + # rubocop:disable Metrics/AbcSize + def update + args = process_byte_size.merge({ plan_id: @plan.id }) + args = process_nillable_values(args: args) + authorize @research_output + + # Clear any existing repository and metadata_standard selections. + @research_output.repositories.clear + @research_output.metadata_standards.clear + + if @research_output.update(args) + redirect_to plan_research_outputs_path(@plan), + notice: success_message(@research_output, _('saved')) + else + redirect_to edit_plan_research_output_path(@plan, @research_output), + alert: failure_message(@research_output, _('save')) + end + end + # rubocop:enable Metrics/AbcSize + + # DELETE /plans/:plan_id/research_outputs/:id + def destroy + authorize @research_output + + if @research_output.destroy + redirect_to plan_research_outputs_path(@plan), + notice: success_message(@research_output, _('removed')) + else + redirect_to plan_research_outputs_path(@plan), + alert: failure_message(@research_output, _('remove')) + end + end + + # ============================ + # = Rails UJS remote methods = + # ============================ + + # GET /plans/:id/output_type_selection + def select_output_type + @plan = Plan.find_by(id: params[:plan_id]) + @research_output = ResearchOutput.new( + plan: @plan, output_type: output_params[:output_type] + ) + authorize @research_output + end + + # GET /plans/:id/license_selection + def select_license + @plan = Plan.find_by(id: params[:plan_id]) + @research_output = ResearchOutput.new( + plan: @plan, license_id: output_params[:license_id] + ) + authorize @research_output + end + + # GET /plans/:id/repository_search + # rubocop:disable Metrics/AbcSize + def repository_search + @plan = Plan.find_by(id: params[:plan_id]) + @research_output = ResearchOutput.new(plan: @plan) + authorize @research_output + + @search_results = Repository.by_type(repo_search_params[:type_filter]) + @search_results = @search_results.by_subject(repo_search_params[:subject_filter]) + @search_results = @search_results.search(repo_search_params[:search_term]) + + @search_results = @search_results.order(:name).page(params[:page]) + end + # rubocop:enable Metrics/AbcSize + + # PUT /plans/:id/repository_select + def repository_select + @plan = Plan.find_by(id: params[:plan_id]) + @research_output = ResearchOutput.new(plan: @plan) + authorize @research_output + + @research_output + end + + # PUT /plans/:id/repository_unselect + def repository_unselect + @plan = Plan.find_by(id: params[:plan_id]) + @research_output = ResearchOutput.new(plan: @plan) + authorize @research_output + end + + # GET /plans/:id/metadata_standard_search + def metadata_standard_search + @plan = Plan.find_by(id: params[:plan_id]) + @research_output = ResearchOutput.new(plan: @plan) + authorize @research_output + + @search_results = MetadataStandard.search(metadata_standard_search_params[:search_term]) + .order(:title) + .page(params[:page]) + end + + private + + def output_params + params.require(:research_output) + .permit(%i[title abbreviation description output_type output_type_description + sensitive_data personal_data file_size file_size_unit mime_type_id + release_date access coverage_start coverage_end coverage_region + mandatory_attribution license_id], + repositories_attributes: %i[id], metadata_standards_attributes: %i[id]) + end + + def repo_search_params + params.require(:research_output).permit(%i[search_term subject_filter type_filter]) + end + + def metadata_standard_search_params + params.require(:research_output).permit(%i[search_term]) + end + + # rubocop:disable Metrics/AbcSize + def process_byte_size + args = output_params + + if args[:file_size].present? + byte_size = 0.bytes + case args[:file_size_unit] + when 'pb' + args[:file_size].to_f.petabytes + when 'tb' + args[:file_size].to_f.terabytes + when 'gb' + args[:file_size].to_f.gigabytes + when 'mb' + args[:file_size].to_f.megabytes + else + args[:file_size].to_i + end + + args[:byte_size] = byte_size + end + + args.delete(:file_size) + args.delete(:file_size_unit) + args + end + # rubocop:enable Metrics/AbcSize + + # There are certain fields on the form that are visible based on the selected output_type. If the + # ResearchOutput previously had a value for any of these and the output_type then changed making + # one of these arguments invisible, then we need to blank it out here since the Rails form will + # not send us the value + def process_nillable_values(args:) + args[:byte_size] = nil unless args[:byte_size].present? + args + end + + # ============= + # = Callbacks = + # ============= + + def fetch_plan + @plan = Plan.find_by(id: params[:plan_id]) + return true if @plan.present? + + redirect_to root_path, alert: _('plan not found') + end + + def fetch_research_output + @research_output = ResearchOutput.includes(:repositories) + .find_by(id: params[:id]) + return true if @research_output.present? && + @plan.research_outputs.include?(@research_output) + + redirect_to plan_research_outputs_path, alert: _('research output not found') + end +end diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js index 1c83822daa..f61459d552 100644 --- a/app/javascript/packs/application.js +++ b/app/javascript/packs/application.js @@ -26,6 +26,7 @@ import 'bootstrap-select'; import '../src/utils/accordion'; import '../src/utils/autoComplete'; import '../src/utils/externalLink'; +import '../src/utils/modalSearch'; import '../src/utils/outOfFocus'; import '../src/utils/paginable'; import '../src/utils/panelHeading'; @@ -58,6 +59,7 @@ import '../src/plans/index.js.erb'; import '../src/plans/new'; import '../src/plans/share'; import '../src/publicTemplates/show'; +import '../src/researchOutputs/form'; import '../src/roles/edit'; import '../src/shared/createAccountForm'; import '../src/shared/signInForm'; diff --git a/app/javascript/src/researchOutputs/form.js b/app/javascript/src/researchOutputs/form.js new file mode 100644 index 0000000000..b454b9eb3d --- /dev/null +++ b/app/javascript/src/researchOutputs/form.js @@ -0,0 +1,45 @@ +import getConstant from '../utils/constants'; +import { isUndefined, isObject } from '../utils/isType'; +import { Tinymce } from '../utils/tinymce.js.erb'; + +$(() => { + const form = $('.research_output_form'); + + if (!isUndefined(form) && isObject(form)) { + Tinymce.init({ selector: '#research_output_description' }); + } + + // Expands/Collapses the search results 'More info'/'Less info' section + $('body').on('click', '.modal-search-result .more-info a.more-info-link', (e) => { + e.preventDefault(); + const link = $(e.target); + + if (link.length > 0) { + const info = $(link).siblings('div.info'); + + if (info.length > 0) { + if (info.hasClass('hidden')) { + info.removeClass('hidden'); + link.text(`${getConstant('LESS_INFO')}`); + } else { + info.addClass('hidden'); + link.text(`${getConstant('MORE_INFO')}`); + } + } + } + }); + + // Put the facet text into the modal search window's search box when the user + // clicks on one + $('body').on('click', '.modal-search-result a.facet', (e) => { + const link = $(e.target); + + if (link.length > 0) { + const textField = link.closest('.modal-body').find('input.autocomplete'); + + if (textField.length > 0) { + textField.val(link.text()); + } + } + }); +}); diff --git a/app/javascript/src/utils/modalSearch.js b/app/javascript/src/utils/modalSearch.js new file mode 100644 index 0000000000..2bc8bf1888 --- /dev/null +++ b/app/javascript/src/utils/modalSearch.js @@ -0,0 +1,39 @@ +$(() => { + // Add the selected item to the selections section + $('body').on('click', 'a.modal-search-result-selector', (e) => { + e.preventDefault(); + const link = $(e.target); + + if (link.length > 0) { + const selectedBlock = $(e.target).closest('.modal-search-result'); + const resultsBlock = $(e.target).closest('.modal-search-results'); + + if (resultsBlock.length > 0 && selectedBlock.length > 0) { + const selectionsBlockId = resultsBlock.attr('id').replace('-results', '-selections'); + + if (selectionsBlockId !== undefined) { + const selectionsBlock = $(`#${selectionsBlockId}`); + + if (selectionsBlock.length > 0) { + const clone = selectedBlock.clone(); + clone.find('.modal-search-result-selector').addClass('hidden'); + clone.find('.modal-search-result-unselector').removeClass('hidden'); + clone.find('.tags').remove(); + selectionsBlock.append(clone); + selectedBlock.remove(); + } + } + } + } + }); + + // Remove the selected item + $('body').on('click', 'a.modal-search-result-unselector', (e) => { + e.preventDefault(); + const selection = $(e.target).closest('.modal-search-result'); + + if (selection.length > 0) { + selection.remove(); + } + }); +}); diff --git a/app/models/application_record.rb b/app/models/application_record.rb index 3fd5039392..fc9717e959 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -8,6 +8,31 @@ class ApplicationRecord < ActiveRecord::Base self.abstract_class = true + class << self + # Indicates whether the underlying DB is MySQL + def mysql_db? + ActiveRecord::Base.connection.adapter_name == 'Mysql2' + end + + def postgres_db? + ActiveRecord::Base.connection.adapter_name == 'PostgreSQL' + end + + # Generates the appropriate where clause for a JSON field based on the DB type + def safe_json_where_clause(column:, hash_key:) + return "(#{column}->>'#{hash_key}' LIKE ?)" unless mysql_db? + + "(#{column}->>'$.#{hash_key}' LIKE ?)" + end + + # Generates the appropriate where clause for a regular expression based on the DB type + def safe_regexp_where_clause(column:) + return "#{column} ~* ?" unless mysql_db? + + "#{column} REGEXP ?" + end + end + def sanitize_fields(*attrs) attrs.each do |attr| send("#{attr}=", ActionController::Base.helpers.sanitize(send(attr))) diff --git a/app/models/concerns/acts_as_sortable.rb b/app/models/concerns/acts_as_sortable.rb index 537d24bbda..3ec73d2725 100644 --- a/app/models/concerns/acts_as_sortable.rb +++ b/app/models/concerns/acts_as_sortable.rb @@ -11,12 +11,10 @@ def update_numbers!(ids, parent:) ids = ids.map(&:to_i) & parent.public_send("#{model_name.singular}_ids") return if ids.empty? - case connection.adapter_name - when 'PostgreSQL' then update_numbers_postgresql!(ids) - when 'Mysql2' then update_numbers_mysql2!(ids) - else - update_numbers_sequentially!(ids) - end + update_numbers_postgresql!(ids) if ApplicationRecord.postgres_db? + update_numbers_mysql2!(ids) if ApplicationRecord.mysql_db? + update_numbers_sequentially!(ids) unless ApplicationRecord.postgres_db? || + ApplicationRecord.mysql_db? end private diff --git a/app/models/license.rb b/app/models/license.rb new file mode 100644 index 0000000000..e59f74250d --- /dev/null +++ b/app/models/license.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: licenses +# +# id :bigint not null, primary key +# deprecated :boolean default(FALSE) +# identifier :string not null +# name :string not null +# osi_approved :boolean default(FALSE) +# uri :string not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_license_on_identifier_and_criteria (identifier,osi_approved,deprecated) +# index_licenses_on_identifier (identifier) +# index_licenses_on_uri (uri) +# +class License < ApplicationRecord + # ================ + # = Associations = + # ================ + + has_many :research_outputs + + # ========== + # = Scopes = + # ========== + + scope :selectable, lambda { + where(deprecated: false) + } + + scope :preferred, lambda { + # Fetch the list of preferred license from the config. + preferences = Rails.configuration.x.madmp.preferred_licenses || [] + return selectable unless preferences.is_a?(Array) && preferences.any? + + licenses = preferences.map do |preference| + # If `%{latest}` was specified then grab the most current version + pref = preference.gsub('%{latest}', '[0-9\\.]+$') + where_clause = safe_regexp_where_clause(column: 'identifier') + rslts = preference.include?('%{latest}') ? where(where_clause, pref) : where(identifier: pref) + rslts.order(:identifier).last + end + # Remove any preferred licenses that could not be found in the table + licenses.compact + } + + # varchar(255) NOT NULL + validates :name, + presence: { message: PRESENCE_MESSAGE }, + length: { in: 0..255, allow_nil: false } + + # varchar(255) NOT NULL + validates :identifier, + presence: { message: PRESENCE_MESSAGE }, + length: { in: 0..255, allow_nil: false } + + # varchar(255) NOT NULL + validates :uri, + presence: { message: PRESENCE_MESSAGE }, + length: { in: 0..255, allow_nil: false } +end diff --git a/app/models/metadata_standard.rb b/app/models/metadata_standard.rb new file mode 100644 index 0000000000..02eaccfd1f --- /dev/null +++ b/app/models/metadata_standard.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: metadata_standards +# +# id :bigint not null, primary key +# description :text +# locations :json +# related_entities :json +# title :string +# uri :string +# created_at :datetime not null +# updated_at :datetime not null +# rdamsc_id :string +# +class MetadataStandard < ApplicationRecord + # ============= + # = Constants = + # ============= + + # keep "=>" syntax as json_schemer requires string keys + SCHEMA_RELATED_ENTITIES = { + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'type' => 'array', + 'items' => { + 'type' => 'object', + 'properties' => { + 'id' => { 'type' => 'string' }, + 'role' => { 'type' => 'string' } + } + } + }.freeze + + SCHEMA_LOCATIONS = { + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'type' => 'array', + 'items' => { + 'type' => 'object', + 'properties' => { + 'url' => { 'type' => 'string' }, + 'type' => { 'type' => 'string' } + } + } + }.freeze + + # ================ + # = Associations = + # ================ + + has_and_belongs_to_many :research_outputs + + # ========== + # = Scopes = + # ========== + + scope :search, lambda { |term| + term = term.downcase + where('LOWER(title) LIKE ?', "%#{term}%").or(where('LOWER(description) LIKE ?', "%#{term}%")) + } + + # varchar(255) DEFAULT NULL + validates :title, + length: { maximum: 255 } + + # varchar(255) DEFAULT NULL + validates :rdamsc_id, + length: { maximum: 255 } + + # varchar(255) DEFAULT NULL + validates :uri, + length: { maximum: 255 } + + # json DEFAULT NULL + validates :related_entities, + json: { + schema: SCHEMA_RELATED_ENTITIES, + message: ->(errors) { errors } + }, + allow_nil: true + + # json DEFAULT NULL + validates :locations, + json: { + schema: SCHEMA_LOCATIONS, + message: ->(errors) { errors } + }, + allow_nil: true +end diff --git a/app/models/plan.rb b/app/models/plan.rb index e8842d5365..0bf4e69a58 100644 --- a/app/models/plan.rb +++ b/app/models/plan.rb @@ -20,6 +20,7 @@ # org_id :integer # funder_id :integer # grant_id :integer +# api_client_id :integer # research_domain_id :bigint # funding_status :integer # ethical_issues :boolean @@ -37,10 +38,12 @@ # # fk_rails_... (template_id => templates.id) # fk_rails_... (org_id => orgs.id) +# fk_rails_... (api_client_id => api_clients.id) # fk_rails_... (research_domain_id => research_domains.id) # # Object that represents an DMP +# rubocop:disable Metrics/ClassLength class Plan < ApplicationRecord include ConditionalUserMailer include ExportablePlan @@ -84,6 +87,8 @@ class Plan < ApplicationRecord belongs_to :funder, class_name: 'Org', optional: true + belongs_to :api_client, optional: true + belongs_to :research_domain, optional: true has_many :phases, through: :template @@ -111,10 +116,14 @@ class Plan < ApplicationRecord has_and_belongs_to_many :guidance_groups, join_table: :plans_guidance_groups - has_many :exported_plans + has_many :exported_plans, dependent: :destroy has_many :contributors, dependent: :destroy + has_one :grant, as: :identifiable, dependent: :destroy, class_name: 'Identifier' + + has_many :research_outputs, dependent: :destroy + # ===================== # = Nested Attributes = # ===================== @@ -608,3 +617,4 @@ def end_date_after_start_date errors.add(:end_date, _('must be after the start date')) if end_date < start_date end end +# rubocop:enable Metrics/ClassLength diff --git a/app/models/repository.rb b/app/models/repository.rb new file mode 100644 index 0000000000..344dcb889b --- /dev/null +++ b/app/models/repository.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: repositories +# +# id :bigint not null, primary key +# contact :string +# description :text not null +# info :json +# name :string not null +# homepage :string +# created_at :datetime not null +# updated_at :datetime not null +# uri :string not null +# +# Indexes +# +# index_repositories_on_name (name) +# index_repositories_on_homepage (homepage) +# index_repositories_on_uri (uri) +# +# Object that represents a research output repository (e.g. GitHub or Zenodo) +class Repository < ApplicationRecord + # ============= + # = Constants = + # ============= + + # keep "=>" syntax as json_schemer requires string keys + SCHEMA_INFO = { + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'type' => 'object', + 'properties' => { + 'types' => { + 'type' => 'array', + 'items' => { + 'type' => 'string' + } + }, + 'keywords' => { + 'type' => 'array', + 'items' => { + 'type' => 'string' + } + }, + 'subjects' => { + 'type' => 'array', + 'items' => { + 'type' => 'string' + } + }, + 'access' => { + 'type' => 'string', + 'enum' => %w[open restricted closed] + }, + 'provider_types' => { + 'type' => 'array', + 'items' => { + 'type' => 'string' + } + }, + 'upload_types' => { + 'type' => 'array', + 'items' => { + 'type' => 'object', + 'properties' => { + 'type' => { 'type' => 'string' }, + 'restriction' => { 'type' => 'string' } + }, + 'required' => %w[type restriction] + } + }, + 'policies' => { + 'type' => 'array', + 'items' => { + 'type' => 'object', + 'properties' => { + 'name' => { 'type' => 'string' }, + 'url' => { 'type' => 'string' } + }, + 'required' => %w[name url] + } + }, + 'pid_system' => { + 'type' => 'string' + } + } + }.freeze + + # ================ + # = Associations = + # ================ + + has_and_belongs_to_many :research_outputs + + # ========== + # = Scopes = + # ========== + + scope :by_type, lambda { |type| + where(safe_json_where_clause(column: 'info', hash_key: 'types'), "%#{type}%") + } + + scope :by_subject, lambda { |subject| + where(safe_json_where_clause(column: 'info', hash_key: 'subjects'), "%#{subject}%") + } + + scope :search, lambda { |term| + term = term.downcase + where('LOWER(name) LIKE ?', "%#{term}%") + .or(where(safe_json_where_clause(column: 'info', hash_key: 'keywords'), "%#{term}%")) + .or(where(safe_json_where_clause(column: 'info', hash_key: 'subjects'), "%#{term}%")) + } + + # A very specific keyword search (e.g. 'gene', 'DNA', etc.) + scope :by_facet, lambda { |facet| + where(safe_json_where_clause(column: 'info', hash_key: 'keywords'), "%#{facet}%") + } + + # =============== + # = Validations = + # =============== + + # varchar(255) NOT NULL + validates :name, + presence: { message: PRESENCE_MESSAGE }, + length: { in: 0..255, allow_nil: false } + + # text NOT NULL + validates :description, + presence: { message: PRESENCE_MESSAGE } + + # varchar(255) NOT NULL + validates :uri, + presence: { message: PRESENCE_MESSAGE }, + length: { in: 0..255, allow_nil: false } + + # varchar(255) DEFAULT NULL + validates :homepage, + length: { maximum: 255 } + + # varchar(255) DEFAULT NULL + validates :contact, + length: { maximum: 255 } + + # json DEFAULT NULL + validates :info, + json: { + schema: SCHEMA_INFO, + message: ->(errors) { errors } + }, + allow_nil: true +end diff --git a/app/models/research_output.rb b/app/models/research_output.rb index 7d90efd8f4..9ecfdf49fe 100644 --- a/app/models/research_output.rb +++ b/app/models/research_output.rb @@ -6,12 +6,12 @@ # # id :bigint not null, primary key # abbreviation :string -# access :integer default(0), not null +# access :integer default("open"), not null # byte_size :bigint # description :text # display_order :integer -# is_default :boolean default("false") -# output_type :integer default(3), not null +# is_default :boolean +# output_type :integer default("dataset"), not null # output_type_description :string # personal_data :boolean # release_date :datetime @@ -19,13 +19,17 @@ # title :string not null # created_at :datetime not null # updated_at :datetime not null -# mime_type_id :integer +# license_id :bigint # plan_id :integer # # Indexes # # index_research_outputs_on_output_type (output_type) -# index_research_outputs_on_plan_id (plan_id) +# +# Foreign Keys +# +# fk_rails_... (plan_id => plans.id) +# fk_rails_... (license_id => licenses.id) # # Object that represents a proposed output for a project @@ -43,14 +47,22 @@ class ResearchOutput < ApplicationRecord # = Associations = # ================ - belongs_to :plan, optional: true + belongs_to :plan, optional: true, touch: true + belongs_to :license, optional: true + + has_and_belongs_to_many :metadata_standards + has_and_belongs_to_many :repositories # =============== # = Validations = # =============== validates_presence_of :output_type, :access, :title, message: PRESENCE_MESSAGE - validates_uniqueness_of :title, :abbreviation, scope: :plan_id + validates_uniqueness_of :title, { case_sensitive: false, scope: :plan_id, + message: UNIQUENESS_MESSAGE } + validates_uniqueness_of :abbreviation, { case_sensitive: false, scope: :plan_id, + allow_nil: true, allow_blank: true, + message: UNIQUENESS_MESSAGE } # Ensure presence of the :output_type_description if the user selected 'other' validates_presence_of :output_type_description, if: -> { other? }, message: PRESENCE_MESSAGE @@ -59,36 +71,17 @@ class ResearchOutput < ApplicationRecord # = Instance methods = # ==================== - # TODO: placeholders for once the License, Repository, Metadata Standard and - # Resource Type Lookups feature is built. - # - # Be sure to add the scheme in the appropriate upgrade task (and to the - # seed.rb as well) - def licenses - # scheme = IdentifierScheme.find_by(name: '[name of license scheme]') - # return [] unless scheme.present? - # identifiers.select { |id| id.identifier_scheme = scheme } - [] - end - - def repositories - # scheme = IdentifierScheme.find_by(name: '[name of repository scheme]') - # return [] unless scheme.present? - # identifiers.select { |id| id.identifier_scheme = scheme } - [] - end - - def metadata_standards - # scheme = IdentifierScheme.find_by(name: '[name of openaire scheme]') - # return [] unless scheme.present? - # identifiers.select { |id| id.identifier_scheme = scheme } - [] + # Helper method to convert selected repository form params into Repository objects + def repositories_attributes=(params) + params.each do |_i, repository_params| + repositories << Repository.find_by(id: repository_params[:id]) + end end - def resource_types - # scheme = IdentifierScheme.find_by(name: '[name of resource_type scheme]') - # return [] unless scheme.present? - # identifiers.select { |id| id.identifier_scheme = scheme } - [] + # Helper method to convert selected metadata standard form params into MetadataStandard objects + def metadata_standards_attributes=(params) + params.each do |_i, metadata_standard_params| + metadata_standards << MetadataStandard.find_by(id: metadata_standard_params[:id]) + end end end diff --git a/app/models/user.rb b/app/models/user.rb index 86fa017a75..c3ff357961 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -142,7 +142,7 @@ class User < ApplicationRecord # MySQL does not support standard string concatenation and since concat_ws # or concat functions do not exist for sqlite, we have to come up with this # conditional - if ActiveRecord::Base.connection.adapter_name == 'Mysql2' + if mysql_db? where("lower(concat_ws(' ', firstname, surname)) LIKE lower(?) OR " \ 'lower(email) LIKE lower(?)', search_pattern, search_pattern) diff --git a/app/policies/research_output_policy.rb b/app/policies/research_output_policy.rb new file mode 100644 index 0000000000..defe86a0c9 --- /dev/null +++ b/app/policies/research_output_policy.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +# Security policies for research outputs +class ResearchOutputPolicy < ApplicationPolicy + attr_reader :user, :research_output + + def initialize(user, research_output) + raise Pundit::NotAuthorizedError, _('must be logged in') unless user + + raise Pundit::NotAuthorizedError, _('are not authorized to view that plan') unless research_output.present? + + @user = user + @research_output = research_output + super + end + + def index? + @research_output.plan.readable_by?(@user.id) + end + + def new? + @research_output.plan.administerable_by?(@user.id) + end + + def edit? + @research_output.plan.administerable_by?(@user.id) + end + + def create? + @research_output.plan.administerable_by?(@user.id) + end + + def update? + @research_output.plan.administerable_by?(@user.id) + end + + def destroy? + @research_output.plan.administerable_by?(@user.id) + end + + def select_output_type? + @research_output.plan.administerable_by?(@user.id) + end + + def select_license? + @research_output.plan.administerable_by?(@user.id) + end + + def repository_search? + @research_output.plan.administerable_by?(@user.id) + end + + def metadata_standard_search? + @research_output.plan.administerable_by?(@user.id) + end +end diff --git a/app/presenters/api/v1/api_presenter.rb b/app/presenters/api/v1/api_presenter.rb new file mode 100644 index 0000000000..692adef0cf --- /dev/null +++ b/app/presenters/api/v1/api_presenter.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Api + module V1 + # Generic helper methods for API V1 + class ApiPresenter + class << self + def boolean_to_yes_no_unknown(value:) + return 'unknown' unless value.present? + + value ? 'yes' : 'no' + end + end + end + end +end diff --git a/app/presenters/api/v1/research_output_presenter.rb b/app/presenters/api/v1/research_output_presenter.rb new file mode 100644 index 0000000000..28b7325312 --- /dev/null +++ b/app/presenters/api/v1/research_output_presenter.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +module Api + module V1 + # Helper methods for research outputs + class ResearchOutputPresenter + attr_reader :dataset_id, :preservation_statement, :security_and_privacy, :license_start_date, + :data_quality_assurance, :distributions, :metadata, :technical_resources + + def initialize(output:) + @research_output = output + return unless output.is_a?(ResearchOutput) + + @plan = output.plan + @dataset_id = identifier + + load_narrative_content + + @license_start_date = determine_license_start_date(output: output) + end + + private + + def identifier + Identifier.new(identifiable: @research_output, value: @research_output.id) + end + + def determine_license_start_date(output:) + return nil unless output.present? + return output.release_date.to_formatted_s(:iso8601) if output.release_date.present? + + output.created_at.to_formatted_s(:iso8601) + end + + def load_narrative_content + @preservation_statement = '' + @security_and_privacy = [] + @data_quality_assurance = '' + + # Disabling rubocop here since a guard clause would make the line too long + # rubocop:disable Style/GuardClause + if Rails.configuration.x.madmp.extract_preservation_statements_from_themed_questions + @preservation_statement = fetch_q_and_a_as_single_statement(themes: %w[Preservation]) + end + if Rails.configuration.x.madmp.extract_security_privacy_statements_from_themed_questions + @security_and_privacy = fetch_q_and_a(themes: ['Ethics & privacy', 'Storage & security']) + end + if Rails.configuration.x.madmp.extract_data_quality_statements_from_themed_questions + @data_quality_assurance = fetch_q_and_a_as_single_statement(themes: ['Data Collection']) + end + # rubocop:enable Style/GuardClause + end + + def fetch_q_and_a_as_single_statement(themes:) + fetch_q_and_a(themes: themes).collect { |item| item[:description] }.join('
') + end + + # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + def fetch_q_and_a(themes:) + return [] unless themes.is_a?(Array) && themes.any? + + ret = themes.map do |theme| + qs = @plan.questions.select { |q| q.themes.collect(&:title).include?(theme) } + descr = qs.map do |q| + a = @plan.answers.select { |ans| ans.question_id = q.id }.first + next unless a.present? && !a.blank? + + "Question: #{q.text}
Answer: #{a.text}" + end + { title: theme, description: descr } + end + ret.select { |item| item[:description].present? } + end + # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + end + end +end diff --git a/app/presenters/research_output_presenter.rb b/app/presenters/research_output_presenter.rb new file mode 100644 index 0000000000..3a77f1be80 --- /dev/null +++ b/app/presenters/research_output_presenter.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +# Helper methods for the research outputs tab +class ResearchOutputPresenter + attr_accessor :research_output + + def initialize(research_output:) + @research_output = research_output + end + + # Returns the output_type list for a select_tag + def selectable_output_types + ResearchOutput.output_types + .map { |k, _v| [k.humanize, k] } + end + + # Returns the access options for a select tag + def selectable_access_types + ResearchOutput.accesses + .map { |k, _v| [k.humanize, k] } + end + + # Returns the options for file size units + def selectable_size_units + [%w[MB mb], %w[GB gb], %w[TB tb], %w[PB pb], ['bytes', '']] + end + + # Returns the options for metadata standards + def selectable_metadata_standards(category:) + out = MetadataStandard.all.order(:title).map { |ms| [ms.title, ms.id] } + return out unless category.present? + + MetadataStandard.where(descipline_specific: (category == 'disciplinary')) + .map { |ms| [ms.title, ms.id] } + end + + # Returns the available licenses for a select tag + def complete_licenses + License.selectable + .sort { |a, b| a.identifier <=> b.identifier } + .map { |license| [license.identifier, license.id] } + end + + # Returns the available licenses for a select tag + def preferred_licenses + License.preferred.map { |license| [license.identifier, license.id] } + end + + # Returns whether or not we should capture the byte_size based on the output_type + def byte_sizable? + @research_output.audiovisual? || @research_output.sound? || @research_output.image? || + @research_output.model_representation? || + @research_output.data_paper? || @research_output.dataset? || @research_output.text? + end + + # Returns the options for subjects for the repository filter + def self.selectable_subjects + [ + '23-Agriculture, Forestry, Horticulture and Veterinary Medicine', + '21-Biology', + '31-Chemistry', + '44-Computer Science, Electrical and System Engineering', + '45-Construction Engineering and Architecture', + '34-Geosciences (including Geography)', + '11-Humanities', + '43-Materials Science and Engineering', + '33-Mathematics', + '41-Mechanical and industrial Engineering', + '22-Medicine', + '32-Physics', + '12-Social and Behavioural Sciences', + '42-Thermal Engineering/Process Engineering' + ].map do |subject| + [subject.split('-').last, subject.gsub('-', ' ')] + end + end + + # Returns the options for the repository type + def self.selectable_repository_types + [ + [_('Generalist (multidisciplinary)'), 'other'], + [_('Discipline specific'), 'disciplinary'], + [_('Institutional'), 'institutional'] + ] + end + + # Converts the byte_size into a more friendly value (e.g. 15.4 MB) + # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity + def converted_file_size(size:) + return { size: nil, unit: 'mb' } unless size.present? && size.is_a?(Numeric) && size.positive? + return { size: size / 1.petabytes, unit: 'pb' } if size >= 1.petabytes + return { size: size / 1.terabytes, unit: 'tb' } if size >= 1.terabytes + return { size: size / 1.gigabytes, unit: 'gb' } if size >= 1.gigabytes + return { size: size / 1.megabytes, unit: 'mb' } if size >= 1.megabytes + + { size: size, unit: '' } + end + # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity + + # Returns the truncated title if it is greater than 50 characters + def display_name + return '' unless @research_output.is_a?(ResearchOutput) + return "#{@research_output.title[0..49]} ..." if @research_output.title.length > 50 + + @research_output.title + end + + # Returns the humanized version of the output_type enum variable + def display_type + return '' unless @research_output.is_a?(ResearchOutput) + # Return the user entered text for the type if they selected 'other' + return @research_output.output_type_description if @research_output.other? + + @research_output.output_type.gsub('_', ' ').capitalize + end + + # Returns the display name(s) of the repository(ies) + def display_repository + return [_('None specified')] unless @research_output.repositories.any? + + @research_output.repositories.map(&:name) + end + + # Returns the display the license name + def display_license + return _('None specified') unless @research_output.license.present? + + @research_output.license.name + end + + # Returns the display name(s) of the repository(ies) + def display_metadata_standard + return [_('None specified')] unless @research_output.metadata_standards.any? + + @research_output.metadata_standards.map(&:title) + end + + # Returns the humanized version of the access enum variable + def display_access + return _('Unspecified') unless @research_output.access.present? + + @research_output.access.capitalize + end + + # Returns the release date as a date + def display_release + return _('Unspecified') unless @research_output.release_date.present? + + @research_output.release_date.to_date + end + + # Return 'Yes', 'No' or 'Unspecified' depending on the value + def display_boolean(value:) + return 'Unspecified' if value.nil? + + value ? 'Yes' : 'No' + end +end diff --git a/app/services/external_apis/rdamsc_service.rb b/app/services/external_apis/rdamsc_service.rb new file mode 100644 index 0000000000..b6299a1a2a --- /dev/null +++ b/app/services/external_apis/rdamsc_service.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +module ExternalApis + # This service provides an interface to the RDA Metadata Standards Catalog (RDAMSC) + # It extracts the list of Metadata Standards using two API endpoints from the first extracts + # the list of subjects/concepts from the thesaurus and the second collects the standards + # (aka schemes) and connects them to their appropriate subjects + # + # UI to see the standards: https://rdamsc.bath.ac.uk/scheme-index + # API: + # https://app.swaggerhub.com/apis-docs/alex-ball/rda-metadata-standards-catalog/2.0.0#/m/get_api2_m + class RdamscService < BaseService + class << self + # Retrieve the config settings from the initializer + def landing_page_url + Rails.configuration.x.rdamsc&.landing_page_url || super + end + + def api_base_url + Rails.configuration.x.rdamsc&.api_base_url || super + end + + def max_pages + Rails.configuration.x.rdamsc&.max_pages || super + end + + def max_results_per_page + Rails.configuration.x.rdamsc&.max_results_per_page || super + end + + def max_redirects + Rails.configuration.x.rdamsc&.max_redirects || super + end + + def active? + Rails.configuration.x.rdamsc&.active || super + end + + def schemes_path + Rails.configuration.x.rdamsc&.schemes_path + end + + def thesaurus_path + Rails.configuration.x.rdamsc&.thesaurus_path + end + + def thesaurai + Rails.configuration.x.rdamsc&.thesaurai + end + + def fetch_metadata_standards + query_schemes(path: "#{schemes_path}?pageSize=250") + end + + private + + # Retrieves the full list of metadata schemes from the rdamsc API as JSON. + # For example: + # { + # "apiVersion": "2.0.0", + # "data": { + # "currentItemCount": 10, + # "items": [ + # { + # "description": "

The Access to Biological Collections Data (ABCD) Schema

", + # "keywords": [ + # "http://vocabularies.unesco.org/thesaurus/concept4011", + # "http://vocabularies.unesco.org/thesaurus/concept230", + # "http://rdamsc.bath.ac.uk/thesaurus/subdomain235", + # "http://vocabularies.unesco.org/thesaurus/concept223", + # "http://vocabularies.unesco.org/thesaurus/concept159", + # "http://vocabularies.unesco.org/thesaurus/concept162", + # "http://vocabularies.unesco.org/thesaurus/concept235" + # ], + # "locations": [ + # { "type": "document", "url": "http://www.tdwg.org/standards/115/" }, + # { "type": "website", "url": "http://wiki.tdwg.org/ABCD" } + # ], + # "mscid": "msc:m1", + # "relatedEntities": [ + # { "id": "msc:m42", "role": "child scheme" }, + # { "id": "msc:m43", "role": "child scheme" }, + # { "id": "msc:m64", "role": "child scheme" }, + # { "id": "msc:c1", "role": "input to mapping" }, + # { "id": "msc:c3", "role": "output from mapping" }, + # { "id": "msc:c14", "role": "output from mapping" }, + # { "id": "msc:c18", "role": "output from mapping" }, + # { "id": "msc:c23", "role": "output from mapping" }, + # { "id": "msc:g11", "role": "user" }, + # { "id": "msc:g44", "role": "user" }, + # { "id": "msc:g45", "role": "user" } + # ], + # "slug": "abcd-access-biological-collection-data", + # "title": "ABCD (Access to Biological Collection Data)", + # "uri": "https://rdamsc.bath.ac.uk/api2/m1" + # } + # ] + # } + # } + def query_schemes(path:) + json = query_api(path: path) + return false unless json.present? + + process_scheme_entries(json: json) + return true unless json.fetch('data', {})['nextLink'].present? + + query_schemes(path: json['data']['nextLink']) + end + + def query_api(path:) + return nil unless path.present? + + # Call the API and log any errors + resp = http_get(uri: "#{api_base_url}#{path}", additional_headers: {}, debug: false) + unless resp.present? && resp.code == 200 + handle_http_failure(method: "RDAMSC API query - path: '#{path}' -- ", http_response: resp) + return nil + end + + JSON.parse(resp.body) + rescue JSON::ParserError => e + log_error(method: "RDAMSC API query - path: '#{path}' -- ", error: e) + nil + end + + # rubocop:disable Metrics/AbcSize + def process_scheme_entries(json:) + return false unless json.is_a?(Hash) + + json = json.with_indifferent_access + return false unless json['data'].present? && json['data'].fetch('items', []).any? + + json['data']['items'].each do |item| + standard = MetadataStandard.find_or_create_by(uri: item['uri'], title: item['title']) + standard.update(description: item['description'], locations: item['locations'], + related_entities: item['relatedEntities'], rdamsc_id: item['mscid']) + end + end + # rubocop:enable Metrics/AbcSize + end + end +end diff --git a/app/services/external_apis/re3data_service.rb b/app/services/external_apis/re3data_service.rb new file mode 100644 index 0000000000..35337e4e70 --- /dev/null +++ b/app/services/external_apis/re3data_service.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +module ExternalApis + # This service provides an interface to the Registry of Research Data + # Repositories (re3data.org) API. + # For more information: https://www.re3data.org/api/doc + class Re3dataService < BaseService + class << self + # Retrieve the config settings from the initializer + def landing_page_url + Rails.configuration.x.re3data&.landing_page_url || super + end + + def api_base_url + Rails.configuration.x.re3data&.api_base_url || super + end + + def max_pages + Rails.configuration.x.re3data&.max_pages || super + end + + def max_results_per_page + Rails.configuration.x.re3data&.max_results_per_page || super + end + + def max_redirects + Rails.configuration.x.re3data&.max_redirects || super + end + + def active? + Rails.configuration.x.re3data&.active || super + end + + def list_path + Rails.configuration.x.re3data&.list_path + end + + def repository_path + Rails.configuration.x.re3data&.repository_path + end + + # Retrieves the full list of repositories from the re3data API as XML. + # For example: + # + # + # r3d100000001 + # Odum Institute Archive Dataverse + # + # + # + def fetch + xml_list = query_re3data + return [] unless xml_list.present? + + xml_list.xpath('/list/repository/id').each do |node| + next unless node.present? && node.text.present? + + xml = query_re3data_repository(repo_id: node.text) + next unless xml.present? + + process_repository(id: node.text, node: xml.xpath('//r3d:re3data//r3d:repository').first) + end + end + + private + + # Queries the re3data API for the full list of repositories + def query_re3data + # Call the ROR API and log any errors + resp = http_get(uri: "#{api_base_url}#{list_path}", additional_headers: {}, + debug: false) + + unless resp.present? && resp.code == 200 + handle_http_failure(method: 're3data list', http_response: resp) + return nil + end + Nokogiri.XML(resp.body, nil, 'utf8') + end + + # Queries the re3data API for the specified repository + def query_re3data_repository(repo_id:) + return [] unless repo_id.present? + + target = "#{api_base_url}#{repository_path}#{repo_id}" + # Call the ROR API and log any errors + resp = http_get(uri: target, additional_headers: {}, + debug: false) + + unless resp.present? && resp.code == 200 + handle_http_failure(method: "re3data repository #{repo_id}", http_response: resp) + return [] + end + Nokogiri.XML(resp.body, nil, 'utf8') + end + + # Updates or Creates a repository based on the XML input + def process_repository(id:, node:) + return nil unless id.present? && node.present? + + # Try to find the Repo by the re3data identifier + repo = Repository.find_by(uri: id) + homepage = node.xpath('//r3d:repositoryURL')&.text + name = node.xpath('//r3d:repositoryName')&.text + repo = Repository.find_by(homepage: homepage) unless repo.present? + repo = Repository.find_or_initialize_by(uri: id, name: name) unless repo.present? + repo = parse_repository(repo: repo, node: node) + repo.reload + end + + # Updates the Repository based on the XML input + # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + def parse_repository(repo:, node:) + return nil unless repo.present? && node.present? + + repo.update( + description: node.xpath('//r3d:description')&.text, + homepage: node.xpath('//r3d:repositoryURL')&.text, + contact: node.xpath('//r3d:repositoryContact')&.text, + info: { + types: node.xpath('//r3d:type').map(&:text), + subjects: node.xpath('//r3d:subject').map(&:text), + provider_types: node.xpath('//r3d:providerType').map(&:text), + keywords: node.xpath('//r3d:keyword').map(&:text), + access: node.xpath('//r3d:databaseAccess//r3d:databaseAccessType')&.text, + pid_system: node.xpath('//r3d:pidSystem')&.text, + policies: node.xpath('//r3d:policy').map { |n| parse_policy(node: n) }, + upload_types: node.xpath('//r3d:dataUpload').map { |n| parse_upload(node: n) } + } + ) + repo + end + # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + + def parse_policy(node:) + return nil unless node.present? + + { + name: node.xpath('r3d:policyName')&.text, + url: node.xpath('r3d:policyURL')&.text + } + end + + def parse_upload(node:) + return nil unless node.present? + + { + type: node.xpath('r3d:dataUploadType')&.text, + restriction: node.xpath('r3d:dataUploadRestriction')&.text + } + end + end + end +end diff --git a/app/services/external_apis/spdx_service.rb b/app/services/external_apis/spdx_service.rb new file mode 100644 index 0000000000..054e96d2a9 --- /dev/null +++ b/app/services/external_apis/spdx_service.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +module ExternalApis + # This service provides an interface to the SPDX License List + # For more information: https://spdx.org/licenses/index.html + class SpdxService < BaseService + class << self + # Retrieve the config settings from the initializer + def landing_page_url + Rails.configuration.x.spdx&.landing_page_url || super + end + + def api_base_url + Rails.configuration.x.spdx&.api_base_url || super + end + + def max_pages + Rails.configuration.x.spdx&.max_pages || super + end + + def max_results_per_page + Rails.configuration.x.spdx&.max_results_per_page || super + end + + def max_redirects + Rails.configuration.x.spdx&.max_redirects || super + end + + def active? + Rails.configuration.x.spdx&.active || super + end + + def list_path + Rails.configuration.x.spdx&.list_path + end + + # Retrieves the full list of license from the SPDX Github repository. + # For example: + # "licenses": [ + # { + # "reference": "./0BSD.html", + # "isDeprecatedLicenseId": false, + # "detailsUrl": "http://spdx.org/licenses/0BSD.json", + # "referenceNumber": "67", + # "name": "BSD Zero Clause License", + # "licenseId": "0BSD", + # "seeAlso": [ + # "http://landley.net/toybox/license.html" + # ], + # "isOsiApproved": true + # } + # ] + def fetch + licenses = query_spdx + return [] unless licenses.present? + + licenses.each { |license| process_license(hash: license) } + License.all + end + + private + + # Queries the re3data API for the full list of repositories + def query_spdx + # Call the ROR API and log any errors + resp = http_get(uri: "#{api_base_url}#{list_path}", additional_headers: {}, debug: false) + + unless resp.present? && resp.code == 200 + handle_http_failure(method: 'SPDX list', http_response: resp) + return [] + end + json = JSON.parse(resp.body) + return [] unless json.fetch('licenses', []).any? + + json['licenses'] + rescue JSON::ParserError => e + log_error(method: 'SPDX search', error: e) + [] + end + + # Updates or Creates a repository based on the XML input + def process_license(hash:) + return nil unless hash.present? + + hash = hash.with_indifferent_access + license = License.find_or_initialize_by(identifier: hash['licenseId']) + return nil unless license.present? + + license.update( + name: hash['name'], + uri: hash['detailsUrl'], + osi_approved: hash['isOsiApproved'], + deprecated: hash['isDeprecatedLicenseId'] + ) + end + end + end +end diff --git a/app/views/api/v1/datasets/_show.json.jbuilder b/app/views/api/v1/datasets/_show.json.jbuilder index cc146a57a3..2aab9e3a7b 100644 --- a/app/views/api/v1/datasets/_show.json.jbuilder +++ b/app/views/api/v1/datasets/_show.json.jbuilder @@ -1,29 +1,83 @@ # frozen_string_literal: true -# locals: plan +# locals: output -presenter = Api::V1::PlanPresenter.new(plan: plan) +if output.is_a?(ResearchOutput) + presenter = Api::V1::ResearchOutputPresenter.new(output: output) -json.title 'Generic Dataset' -json.personal_data 'unknown' -json.sensitive_data 'unknown' + json.type output.output_type + json.title output.title + json.description output.description + json.personal_data Api::V1::ApiPresenter.boolean_to_yes_no_unknown(value: output.personal_data) + json.sensitive_data Api::V1::ApiPresenter.boolean_to_yes_no_unknown(value: output.sensitive_data) + json.issued output.release_date&.to_formatted_s(:iso8601) -json.dataset_id do - json.partial! 'api/v1/identifiers/show', identifier: presenter.identifier -end + json.preservation_statement presenter.preservation_statement + json.security_and_privacy presenter.security_and_privacy + json.data_quality_assurance presenter.data_quality_assurance -json.distribution [plan] do |distribution| - json.title "PDF - #{distribution.title}" - json.data_access 'open' - json.download_url plan_export_url(distribution, format: :pdf) - json.format do - json.array! ['application/pdf'] + json.dataset_id do + json.partial! "api/v1/identifiers/show", identifier: presenter.dataset_id end -end -if plan.research_domain_id.present? - research_domain = ResearchDomain.find_by(id: plan.research_domain_id) - if research_domain.present? - json.keyword [research_domain.label, "#{research_domain.identifier} - #{research_domain.label}"] + json.distribution output.repositories do |repository| + json.title "Anticipated distribution for #{output.title}" + json.byte_size output.byte_size + json.data_access output.access + + json.host do + json.title repository.name + json.description repository.description + json.url repository.homepage + + # DMPTool extensions to the RDA common metadata standard + json.dmproadmap_host_id do + json.type "url" + json.identifier repository.uri + end + end + + if output.license.present? + json.license [output.license] do |license| + json.license_ref license.uri + json.start_date presenter.license_start_date + end + end + end + + json.metadata output.metadata_standards do |metadata_standard| + website = metadata_standard.locations.select { |loc| loc["type"] == "website" }.first + website = { url: "" } unless website.present? + + descr_array = [metadata_standard.title, metadata_standard.description, website["url"]] + json.description descr_array.join(" - ") + + json.metadata_standard_id do + json.type "url" + json.identifier metadata_standard.uri + end + end + + json.technical_resource [] + + if output.plan.research_domain_id.present? + research_domain = ResearchDomain.find_by(id: output.plan.research_domain_id) + if research_domain.present? + combined = "#{research_domain.identifier} - #{research_domain.label}" + json.keyword [research_domain.label, combined] + end + end + +else + json.type "dataset" + json.title "Generic dataset" + json.description "No individual datasets have been defined for this DMP." + + if output.research_domain_id.present? + research_domain = ResearchDomain.find_by(id: output.research_domain_id) + if research_domain.present? + combined = "#{research_domain.identifier} - #{research_domain.label}" + json.keyword [research_domain.label, combined] + end end end diff --git a/app/views/api/v1/plans/_show.json.jbuilder b/app/views/api/v1/plans/_show.json.jbuilder index dd6fd7c33a..f5cbc87c35 100644 --- a/app/views/api/v1/plans/_show.json.jbuilder +++ b/app/views/api/v1/plans/_show.json.jbuilder @@ -55,8 +55,10 @@ unless @minimal json.partial! 'api/v1/plans/project', plan: pln end - json.dataset [plan] do |dataset| - json.partial! 'api/v1/datasets/show', plan: plan, dataset: dataset + outputs = plan.research_outputs.any? ? plan.research_outputs : [plan] + + json.dataset outputs do |output| + json.partial! "api/v1/datasets/show", output: output end json.extension [plan.template] do |template| diff --git a/app/views/layouts/modal_search/README.md b/app/views/layouts/modal_search/README.md new file mode 100644 index 0000000000..74b5fa2f33 --- /dev/null +++ b/app/views/layouts/modal_search/README.md @@ -0,0 +1,155 @@ +# Modal Search + +This modal search allows your user to search for something, select the results they want and then places those selections on your form so that they are part of the form submission. + +To add it to your page, you must render 2 partials: +1. The first adds a 'selected results' section to your form. +2. The second adds the modal dialog which should be placed #outside# of your form, typically at the bottom of the page. + +You must also define the following: +1. Determine a namespace to use that will be unique on your page. You can add multiple modal searches to your page. Using a unique namespace allows the JS to properly manage this functionality. +2. A controller action to perform a search. You will specify this path and method when rendering the modal partial. +3. A `js.erb` that will use the namespace to replace the `modal-search-[namespace]-results` section of the modal window. +4. A partial that defines how an individual result should be displayed. The display of a search result is up to you, this partial will be used in the modal search results section as well as the selected results section. The 'Select' and 'Remove' links will be managed by the modal search code. +5. (Optional) A partial that contains additional filter/search options. The modal search contains a 'search term' box. You can define additional facets/filters as needed. + +## Define an area to display selections + +As noted above, you must add a call to render the `layouts/modal_search/selections` partial. This should live within your form element so that any selections the user makes within the modal search window are passed back to the server upon form submission. See `views/research_outputs/_form.html.erb` for an example of rendering this section and `controllers/research_outputs_controller.rb` (and `models/research_output.rb`) for an example of how to process the user's selections. + +The contents of this section will be populated by the JS in `app/javascript/src/utils/modalSearch.js` when a user clicks on the 'Select' link next to item's title/name. Once the item appears in this section, a 'Remove' link will appear that allows the user to remove it from this section. + +Example screenshot of selected repositories: +![Screenshot of some repositories selected via a modal search](../../../../docs/screenshots/modal_selections.png) + +Example render of this section: +```ruby +var resultsDiv = $('#modal-search-repositories-results'); + +resultsDiv.html('<%= + escape_javascript( + render( + partial: "layouts/modal_search/selections", + locals: { + namespace: "repositories", + button_label: _("Add a repository"), + item_name_attr: :name, + results: research_output.repositories, + selected: true, + result_partial: "research_outputs/repositories/search_result", + search_path: repository_search_plan_path(research_output.plan), + search_method: :get + } + ) + ) %>'); +``` + +Locals: +- #namespace# - a unique name to identify the modal. This value can be used to match a selected result to a section of the parent page. +- #button_label# - the text for the button that opens the modal search window +- #item_name_attr# - The attribute that contains the title/name of the item. +- #results# - any currently selected items +- #selected# - this should be 'true' here. This will ensure that the 'Remove' link gets displayed for the selected items contained in the results. +- #result_partial# - The partial you have defined to display the item's info +- #search_path# - the path to controller endpoint that will perform the search +- #search_method# - the http method used to perform the search + +## Define the modal dialog + +This should be placed outside any form elements you may have defined on your page because it uses its own form element to process the search. + +To add the modal search to your page you must render the form partial. For example: +```ruby +<%= render partial: "layouts/modal_search/form", + locals: { + namespace: "repositories", + label: "Repository", + search_examples: "(e.g. DNA, titanium, FAIR, etc.)", + model_instance: research_output, + search_path: repository_search_plan_path(research_output.plan), + search_method: :get + } %> +``` + +Locals: +- #namespace# - a unique name to identify the modal. This value can be used to match a selected result to a section of the parent page. +- #label# - the text to display on the modal window. This will be swapped in so that it reads: '[label] search' +- #search_examples# - Helpful text that will appear in the search term box as a placeholder to givethe user some suggestions. +- #model_instance# - An instance of the parent object that the search results will be associated to. (e.g. an instance of ResearchOutput if the user will be searching for a license or repository). This is used to help define the `form_with` on the modal search form. +- #search_path# - the path to controller endpoint that will perform the search +- #search_method# - the http method used to perform the search + +Example of the modal window: +![Screenshot of the modal search dialog for repositories](../../../../docs/screenshots/modal_search.png) + +Note that the 'search term' text field box is added by default. The two select boxes are custom filters. See below for info on defining custom filters. + +Once the user clicks the search button, your controller/action will be called and the `layouts/modal_search/results` partial will be rendered by your `js.erb`. The results will be paginated, so be sure to include `.page(params[:page])`in your controller! + +Example of the `js.erb`: + +For example: +```ruby +var resultsDiv = $('#modal-search-repositories-results'); + +resultsDiv.html('<%= + escape_javascript( + render( + partial: "layouts/modal_search/results", + locals: { + namespace: "repositories", + results: @search_results, + selected: false, + item_name_attr: :name, + result_partial: "research_outputs/repositories/search_result", + search_path: repository_search_plan_path(@plan), + search_method: :get + } + ) + ) %>'); +``` + +Locals: +- #namespace# - a unique name to identify the modal. This value can be used to match a selected result to a section of the parent page. +- #results# - any currently selected items +- #selected# - this should be 'false' here. This will ensure that the 'Select' link and pagination controls are displayed. +- #item_name_attr# - The attribute that contains the title/name of the item. +- #result_partial# - The partial you have defined to display the item's info +- #search_path# - the path to controller endpoint that will perform the search. +- #search_method# - the http method used to perform the search + +As the user selects results, the JS will move the result from the modal window to the selections sections described above. + +Note that the modal_search results can work with either an ActiveRecord Model or a Hash! + +## Adding additional search criteria + +By default the modal search will only display the 'search term' text field and an 'Apply filters' button. You can add additional custom filters by supplying content to `yield :filters`. In the screenshot above, you can see 2 additional select boxes that allow the user to further refine the search. + +Example definition of the :filters content: +```ruby +<% content_for :filters do %> + <% + by_type_tooltip = _("Refine your search to discipline specific, institutional or generalist repositories.") + by_subject_tooltip = _("Select a subject area to refine your search.") + %> + + + <%= select_tag :"research_output[subject_filter]", + options_for_select(ResearchOutputPresenter.selectable_subjects), + include_blank: _("- Select a subject area -"), + class: "form-control", + title: by_subject_tooltip, + data: { toggle: "tooltip", placement: "bottom" } %> + + + + <%= select_tag :"research_output[type_filter]", + options_for_select(ResearchOutputPresenter.selectable_repository_types), + include_blank: _("- Select a repository type -"), + class: "form-control", + title: by_type_tooltip, + data: { toggle: "tooltip", placement: "bottom" } %> + +<% end %> +``` diff --git a/app/views/layouts/modal_search/_form.html.erb b/app/views/layouts/modal_search/_form.html.erb new file mode 100644 index 0000000000..a9b469c7bf --- /dev/null +++ b/app/views/layouts/modal_search/_form.html.erb @@ -0,0 +1,89 @@ +<%# +This partial is the entry point for adding the modal search dialog to a page. +See the README.md within this directory for more info: + +Locals: + :namespace - a unique name to identify the modal. This value can be used + to match a selected result to a section of the parent page. + :label - the text to display on the modal window. This will be swapped + in so that it reads: '[label] search' + :search_examples - Helpful text that will appear in the search term box as a + placeholder to givethe user some suggestions. + :model_instance - An instance of the parent object that the search results + will be associated to. (e.g. an instance of ResearchOutput + if the user will be searching for a license or repository). + This is used to help define the `form_with` on the modal search form. + :search_path - the path to controller endpoint that will perform the search + :search_method - the http method used to perform the search +%> + +<% +search_examples = search_examples || "" +results = results || [] + +search_placeholder = _("- Enter a search term %{examples} -") % { examples: search_examples} +no_results_msg = _("No results matched your filter criteria.") +%> + + diff --git a/app/views/layouts/modal_search/_result.html.erb b/app/views/layouts/modal_search/_result.html.erb new file mode 100644 index 0000000000..b7c4a5f736 --- /dev/null +++ b/app/views/layouts/modal_search/_result.html.erb @@ -0,0 +1,37 @@ +<%# +This is calledd by the layouts/modal_search/_results.html.erb partial. + +Locals: +:item_name_attr - The attribute that contains the title/name of the item. +:result - an instance of a result (can be either a Model or a Hash) +:selected - indicates whether this item is within the 'selections' (true) + partial or the 'results' (false) partial +:result_partial - The partial you have defined to display the item's info +:search_path - the path to controller endpoint that will perform the search +:search_method - the http method used to perform the search +%> + +<% title = result[item_name_attr] %> + + diff --git a/app/views/layouts/modal_search/_results.html.erb b/app/views/layouts/modal_search/_results.html.erb new file mode 100644 index 0000000000..c237b76644 --- /dev/null +++ b/app/views/layouts/modal_search/_results.html.erb @@ -0,0 +1,56 @@ +<%# +This is the entry point for the results that are rendered by a `js.erb` file. +See the README.md within this directory for more info: + +Locals: +:namespace - a unique name to identify the modal. This value can be used + to match a selected result to a section of the parent page. +:results - the paginated results of the search +:selected - this should be 'false' here to ensure that the 'Select' link and + pagination controls are displayed. +:item_name_attr - The attribute that contains the title/name of the item. +:result_partial - The partial you have defined to display the item's info +:search_path - the path to controller endpoint that will perform the search +:search_method - the http method used to perform the search +%> + +<% +results = results || [] +selected = selected || false +no_results_msg = _("No results matched your filter criteria.") +%> + +<% unless selected %> + <% if results.any? %> + + <% else %> +
<%= no_results_msg %>
+ <% end %> +<% end %> + +<% results.each do |result| %> + <%= render partial: "layouts/modal_search/result", + locals: { + item_name_attr: item_name_attr, + result: result, + selected: selected, + result_partial: result_partial, + search_path: search_path, + search_method: search_method + }%> +
+<% end %> + +<% if results.any? && !selected %> +
 
+
+ + <%= paginate results, remote: true, method: :post %> + +
+<% end %> diff --git a/app/views/layouts/modal_search/_selections.html.erb b/app/views/layouts/modal_search/_selections.html.erb new file mode 100644 index 0000000000..2ace4f1fec --- /dev/null +++ b/app/views/layouts/modal_search/_selections.html.erb @@ -0,0 +1,35 @@ +<%# +This partial is the entry point for displaying the selected results section of +a modal search window. See the README.md within this directory for more info: + +locals: + :namespace - a unique name to identify the modal. This value can be used + to match a selected result to a section of the parent page. + :button_label - the text for the button that opens the modal search window + :item_name_attr - The attribute that contains the title/name of the item. + :results - any currently selected items + :selected - this should be 'true' here. This will ensure that the 'Remove' + link gets displayed for the selected items contained in the results. + :result_partial - The partial you have defined to display the item's info + :search_path - the path to controller endpoint that will perform the search + :search_method - the http method used to perform the search +%> + + +
+
+ <%= button_tag button_label, type: "button", class: "btn btn-default", + data: { toggle: "modal", target: "#modal-search-#{namespace}" } %> +
+
diff --git a/app/views/paginable/research_outputs/_index.html.erb b/app/views/paginable/research_outputs/_index.html.erb new file mode 100644 index 0000000000..8b89fb7163 --- /dev/null +++ b/app/views/paginable/research_outputs/_index.html.erb @@ -0,0 +1,65 @@ +<%# locals: @plan, scope %> + + + + + + + + + + <% if @plan.administerable_by?(current_user.id) %> + + <% end %> + + + + <% scope.each do |output| %> + <% + presenter = ResearchOutputPresenter.new(research_output: output) + rdate = presenter.display_release + %> + + + + + + + <% if @plan.administerable_by?(current_user.id) %> + + <% end %> + + <% end %> + +
+ <%= _("Title") %> <%= paginable_sort_link("research_outputs.title") %> + + <%= _("Type") %> <%= paginable_sort_link("research_outputs.output_type") %> + + <%= _("Repository") %> + + <%= _("Release date") %> <%= paginable_sort_link("research_outputs.release_date") %> + + <%= _("Access level") %> + + <%= _("Actions") %> +
<%= presenter.display_name %><%= presenter.display_type %><%= presenter.display_repository.join("
").html_safe %>
<%= rdate.is_a?(Date) ? l(rdate, formats: :short) : rdate %><%= presenter.display_access %> + +
diff --git a/app/views/plans/_download_form.html.erb b/app/views/plans/_download_form.html.erb index b56382c57b..d4fe9fae91 100644 --- a/app/views/plans/_download_form.html.erb +++ b/app/views/plans/_download_form.html.erb @@ -39,6 +39,14 @@ <%= _('unanswered questions') %> <% end %> + <% if @plan.research_outputs.any? %> +
+ <%= label_tag 'export[research_outputs]' do %> + <%= check_box_tag 'export[research_outputs]', true, true %> + <%= _('research outputs') %> + <% end %> +
+ <% end %> <% if @plan.template.customization_of.present? %>
<%= label_tag 'export[custom_sections]' do %> diff --git a/app/views/plans/_navigation.html.erb b/app/views/plans/_navigation.html.erb index 8ff5b7d613..468321c9de 100644 --- a/app/views/plans/_navigation.html.erb +++ b/app/views/plans/_navigation.html.erb @@ -17,6 +17,12 @@ <% end %> + <% if Rails.configuration.x.madmp.enable_research_outputs %> +
  • "> + <%= link_to _("Research Outputs"), plan_research_outputs_path(plan), role: "tab", + aria: { controls: "content" } %> +
  • + <% end %> <% if plan.administerable_by?(current_user.id) || (current_user.can_org_admin? && current_user.org.plans.include?(plan)) %>
    <% end %> <% end %> + + <% if @show_research_outputs %> + <%= render partial: 'shared/export/plan_outputs', locals: { outputs: @plan.research_outputs } %> + <% end %> diff --git a/app/views/shared/export/_plan_coversheet.erb b/app/views/shared/export/_plan_coversheet.erb index 370fdf2338..17051e6e7b 100644 --- a/app/views/shared/export/_plan_coversheet.erb +++ b/app/views/shared/export/_plan_coversheet.erb @@ -1,8 +1,10 @@
    -

    <%= @plan.title %>

    +

    <%= _("Plan Overview") %>

    <%= _("A Data Management Plan created using %{application_name}") % { application_name: ApplicationService.application_name } %>


    +

    <%= _("Title: ") %><%= @hash[:title] %>


    +

    <%= _("Creator:") %><%= @hash[:attribution] %>


    <%= _("Affiliation: ") %><%= @hash[:affiliation] %>


    diff --git a/app/views/shared/export/_plan_outputs.erb b/app/views/shared/export/_plan_outputs.erb new file mode 100644 index 0000000000..28e9a71063 --- /dev/null +++ b/app/views/shared/export/_plan_outputs.erb @@ -0,0 +1,51 @@ +<%# locals: outputs %> + +

    <%= _("Planned Research Outputs") %>

    + +<% outputs.each do |output| %> + <% presenter = ResearchOutputPresenter.new(research_output: output) %> +

    <%= "#{presenter.display_type} - \"#{output.title}\"" %>

    + +

    <%= output.description.html_safe %>

    +<% end %> + +


    + +

    <%= _("Planned research output details") %>

    + + + + + + + + + + + + + + + + <% outputs.each do |output| %> + <% presenter = ResearchOutputPresenter.new(research_output: output) %> + <% size_hash = presenter.converted_file_size(size: output.byte_size) %> + + + + + + + + + + + + + <% end %> + +
    <%= _("Title") %><%= _("Type") %><%= _("Anticipated release date") %><%= _("Initial access level") %><%= _("Intended repository(ies)") %><%= _("Anticipated file size") %><%= _("License") %><%= _("Metadata standard(s)") %><%= _("May contain sensitive data?") %><%= _("May contain PII?") %>
    <%= presenter.display_name %><%= presenter.display_type %><%= presenter.display_release %><%= presenter.display_access %><%= sanitize(presenter.display_repository.join("
    ")) %>
    + <% if size_hash[:size].present? %> + <%= "#{number_with_delimiter(size_hash[:size])} #{size_hash[:unit]&.upcase}" %> + <% end %> + <%= presenter.display_license %><%= sanitize(presenter.display_metadata_standard.join("
    ")) %>
    <%= presenter.display_boolean(value: output.sensitive_data) %><%= presenter.display_boolean(value: output.personal_data) %>
    diff --git a/config/initializers/_dmproadmap.rb b/config/initializers/_dmproadmap.rb index 82cabfe3cb..fea780c665 100644 --- a/config/initializers/_dmproadmap.rb +++ b/config/initializers/_dmproadmap.rb @@ -217,7 +217,44 @@ class Application < Rails::Application # --------------------------------------------------- # # Machine Actionable / Networked DMP Features (maDMP) # # --------------------------------------------------- # + # Enable/disable functionality on the Project Details tab config.x.madmp.enable_ethical_issues = false config.x.madmp.enable_research_domain = false + + # This flag will enable/disable the entire Research Outputs tab. The others below will + # just enable/disable specific functionality on the Research Outputs tab + config.x.madmp.enable_research_outputs = false + config.x.madmp.enable_license_selection = false + config.x.madmp.enable_metadata_standard_selection = false + config.x.madmp.enable_repository_selection = false + + # The following flags will allow the system to include the question and answer in the JSON output + # - questions with a theme equal to 'Preservation' + config.x.madmp.extract_preservation_statements_from_themed_questions = false + # - questions with a theme equal to 'Data Collection' + config.x.madmp.extract_data_quality_statements_from_themed_questions = false + # - questions with a theme equal to 'Ethics & privacy' or 'Storage & security' + config.x.madmp.extract_security_privacy_statements_from_themed_questions = false + + # Specify a list of the preferred licenses types. These licenses will appear in a select + # box on the 'Research Outputs' tab when editing a plan along with the option to select + # 'other'. When 'other' is selected, the user is presented with the full list of licenses. + # + # The licenses will appear in the order you specify here. + # + # Note that the values you enter must match the :identifier field of the licenses table. + # You can use the `%{latest}` markup in place of version numbers if desired. + config.x.madmp.preferred_licenses = [ + 'CC-BY-%{latest}s', + 'CC-BY-SA-%{latest}s', + 'CC-BY-NC-%{latest}s', + 'CC-BY-NC-SA-%{latest}s', + 'CC-BY-ND-%{latest}s', + 'CC-BY-NC-ND-%{latest}s', + 'CC0-%{latest}s' + ] + # Link to external guidance about selecting one of the preferred licenses. A default + # URL will be displayed if none is provided here. See app/views/research_outputs/licenses/_form + config.x.madmp.preferred_licenses_guidance_url = 'https://creativecommons.org/about/cclicenses/' end end diff --git a/config/initializers/external_apis/rdamsc.rb b/config/initializers/external_apis/rdamsc.rb new file mode 100644 index 0000000000..f90b83aa16 --- /dev/null +++ b/config/initializers/external_apis/rdamsc.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# Credentials for RDA Metadata Standards Catalog (RDAMSC) +# To disable this feature, simply set 'active' to false +Rails.configuration.x.rdamsc.landing_page_url = "http://rdamsc.bath.ac.uk" +Rails.configuration.x.rdamsc.api_base_url = "https://rdamsc.bath.ac.uk/" +Rails.configuration.x.rdamsc.schemes_path = "api2/m" +Rails.configuration.x.rdamsc.thesaurus_path = "api2/thesaurus/concepts" +Rails.configuration.x.rdamsc.active = true diff --git a/config/initializers/external_apis/re3data.rb b/config/initializers/external_apis/re3data.rb new file mode 100644 index 0000000000..1781626039 --- /dev/null +++ b/config/initializers/external_apis/re3data.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# Credentials for minting DOIs via re3data +# To disable this feature, simply set 'active' to false +Rails.configuration.x.re3data.landing_page_url = "https://www.re3data.org/" +Rails.configuration.x.re3data.api_base_url = "https://www.re3data.org/api/v1/" +Rails.configuration.x.re3data.list_path = "repositories" +Rails.configuration.x.re3data.repository_path = "repository/" +Rails.configuration.x.re3data.active = true diff --git a/config/initializers/external_apis/spdx.rb b/config/initializers/external_apis/spdx.rb new file mode 100644 index 0000000000..559915cd45 --- /dev/null +++ b/config/initializers/external_apis/spdx.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# These configuration settings are used to communicate with the SPDX License repository. +# Licenses are loaded via a Rake task and stored in the local :licenses DB table. +# Please refer to: http://spdx.org/licenses/ +Rails.configuration.x.spdx.landing_page_url = "http://spdx.org/licenses/" +Rails.configuration.x.spdx.api_base_url = "https://raw.githubusercontent.com/spdx/license-list-data/" +Rails.configuration.x.spdx.list_path = "master/json/licenses.json" +Rails.configuration.x.spdx.active = true diff --git a/config/initializers/translation.rb b/config/initializers/translation.rb index 346da2d33b..3fd5ac5394 100644 --- a/config/initializers/translation.rb +++ b/config/initializers/translation.rb @@ -16,7 +16,7 @@ config.target_locales = %w[de en-GB en-US es fr-FR fi sv-FI pt-BR en-CA fr-CA tr-TR] config.text_domain = 'app' config.bound_text_domains = %w[app client] - config.ignored_source_paths = ['app/views/branded/'] + config.ignored_source_paths = ['app/views/branded/', 'node_modules/'] config.locales_path = Rails.root.join('config', 'locale') end elsif ENV['DOMAIN'] == 'client' @@ -38,6 +38,7 @@ def ignore_paths Dir.glob('**/*').select { |f| File.directory? f } .collect { |name| "#{name}/" } - ['app/', + 'node_modules/', 'app/views/', 'app/views/branded/', 'app/views/branded/public_pages/', diff --git a/config/routes.rb b/config/routes.rb index 525a78dbcc..8c7a5d5aa4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -125,6 +125,8 @@ resources :contributors, except: %i[show] + resources :research_outputs, except: %i[show] + member do get 'answer' get 'share' @@ -135,6 +137,17 @@ post 'set_test', constraints: { format: [:json] } get 'overview' end + + # Ajax endpoint for ResearchOutput.output_type selection + get "output_type_selection", controller: "research_outputs", action: "select_output_type" + + # Ajax endpoint for ResearchOutput.license_id selection + get "license_selection", controller: "research_outputs", action: "select_license" + + # AJAX endpoints for repository search and selection + get :repository_search, controller: "research_outputs" + # AJAX endpoints for metadata standards search and selection + get :metadata_standard_search, controller: "research_outputs" end resources :usage, only: [:index] @@ -214,6 +227,10 @@ resources :contributors, only: %i[index] do get 'index/:page', action: :index, on: :collection, as: :index end + # Paginable actions for research_outputs + resources :research_outputs, only: %i[index] do + get "index/:page", action: :index, on: :collection, as: :index + end end # Paginable actions for users diff --git a/db/migrate/20210729204611_madmp_cleanup.rb b/db/migrate/20210729204611_madmp_cleanup.rb index bfea82c298..ec6bc24d87 100644 --- a/db/migrate/20210729204611_madmp_cleanup.rb +++ b/db/migrate/20210729204611_madmp_cleanup.rb @@ -11,10 +11,6 @@ def change remove_column :research_outputs, :coverage_start remove_column :research_outputs, :coverage_end - # We're going to move towards a different solution allowing multiple api_clients - # to have an interest in a plan - remove_column :plans, :api_client_id - # Remove the old principal_investigator and data_contact fields since they now # live in the contributors table remove_column :plans, :data_contact diff --git a/db/migrate/20210802161057_create_repositories.rb b/db/migrate/20210802161057_create_repositories.rb new file mode 100644 index 0000000000..ccb7f31d20 --- /dev/null +++ b/db/migrate/20210802161057_create_repositories.rb @@ -0,0 +1,18 @@ +class CreateRepositories < ActiveRecord::Migration[5.2] + def change + create_table :repositories do |t| + t.string :name, null: false, index: true + t.text :description, null: false + t.string :homepage, index: true + t.string :contact + t.string :uri, null: false, index: true + t.json :info + t.timestamps + end + + create_table :repositories_research_outputs do |t| + t.belongs_to :research_output + t.belongs_to :repository + end + end +end diff --git a/db/migrate/20210802161108_create_licenses.rb b/db/migrate/20210802161108_create_licenses.rb new file mode 100644 index 0000000000..e11e60e669 --- /dev/null +++ b/db/migrate/20210802161108_create_licenses.rb @@ -0,0 +1,15 @@ +class CreateLicenses < ActiveRecord::Migration[5.2] + def change + create_table :licenses do |t| + t.string :name, null: false + t.string :identifier, null: false, index: true + t.string :uri, null: false, index: true + t.boolean :osi_approved, default: false + t.boolean :deprecated, default: false + t.timestamps + t.index [:identifier, :osi_approved, :deprecated], name: "index_license_on_identifier_and_criteria" + end + + add_reference :research_outputs, :license, foreign_key: true + end +end diff --git a/db/migrate/20210802161120_create_metadata_standards.rb b/db/migrate/20210802161120_create_metadata_standards.rb new file mode 100644 index 0000000000..2afc566b6f --- /dev/null +++ b/db/migrate/20210802161120_create_metadata_standards.rb @@ -0,0 +1,18 @@ +class CreateMetadataStandards < ActiveRecord::Migration[5.2] + def change + create_table :metadata_standards do |t| + t.string :title + t.text :description + t.string :rdamsc_id + t.string :uri + t.json :locations + t.json :related_entities + t.timestamps + end + + create_table :metadata_standards_research_outputs do |t| + t.references :metadata_standard, null: true, index: { name: "metadata_research_outputs_on_metadata" } + t.references :research_output, null: true, index: { name: "metadata_research_outputs_on_ro" } + end + end +end diff --git a/db/schema.rb b/db/schema.rb index ab5a3608e3..90b15b2838 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -51,14 +51,20 @@ t.string "description" t.string "homepage" t.string "contact_name" - t.string "contact_email", null: false + t.string "contact_email" t.string "client_id", null: false t.string "client_secret", null: false t.datetime "last_access" t.datetime "created_at", null: false t.datetime "updated_at", null: false t.integer "org_id" - t.index ["name"], name: "index_api_clients_on_name" + t.text "redirect_uri" + t.string "scopes", default: "", null: false + t.boolean "confidential", default: true + t.boolean "trusted", default: false + t.integer "callback_method" + t.string "callback_uri" + t.index ["name"], name: "index_oauth_applications_on_name" end create_table "conditions", id: :integer, force: :cascade do |t| @@ -83,6 +89,7 @@ t.datetime "created_at" t.datetime "updated_at" t.index ["email"], name: "index_contributors_on_email" + t.index ["name", "id", "org_id"], name: "index_contrib_id_and_org_id" t.index ["org_id"], name: "index_contributors_on_org_id" t.index ["plan_id"], name: "index_contributors_on_plan_id" t.index ["roles"], name: "index_contributors_on_roles" @@ -106,6 +113,21 @@ t.integer "phase_id" end + create_table "external_api_access_tokens", force: :cascade do |t| + t.bigint "user_id", null: false + t.string "external_service_name", null: false + t.string "access_token", null: false + t.string "refresh_token" + t.datetime "expires_at" + t.datetime "revoked_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["expires_at"], name: "index_external_api_access_tokens_on_expires_at" + t.index ["external_service_name"], name: "index_external_api_access_tokens_on_external_service_name" + t.index ["user_id", "external_service_name"], name: "index_external_tokens_on_user_and_service" + t.index ["user_id"], name: "index_external_api_access_tokens_on_user_id" + end + create_table "guidance_groups", id: :integer, force: :cascade do |t| t.string "name" t.integer "org_id" @@ -134,6 +156,7 @@ t.string "logo_url" t.string "identifier_prefix" t.integer "context" + t.string "external_service" end create_table "identifiers", id: :integer, force: :cascade do |t| @@ -156,6 +179,37 @@ t.boolean "default_language" end + create_table "licenses", force: :cascade do |t| + t.string "name", null: false + t.string "identifier", null: false + t.string "uri", null: false + t.boolean "osi_approved", default: false + t.boolean "deprecated", default: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["identifier", "osi_approved", "deprecated"], name: "index_license_on_identifier_and_criteria" + t.index ["identifier"], name: "index_licenses_on_identifier" + t.index ["uri"], name: "index_licenses_on_uri" + end + + create_table "metadata_standards", force: :cascade do |t| + t.string "title" + t.text "description" + t.string "rdamsc_id" + t.string "uri" + t.json "locations" + t.json "related_entities" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + create_table "metadata_standards_research_outputs", force: :cascade do |t| + t.bigint "metadata_standard_id" + t.bigint "research_output_id" + t.index ["metadata_standard_id"], name: "metadata_research_outputs_on_metadata" + t.index ["research_output_id"], name: "metadata_research_outputs_on_ro" + end + create_table "notes", id: :integer, force: :cascade do |t| t.integer "user_id" t.text "text" @@ -217,6 +271,8 @@ t.text "feedback_msg" t.string "contact_name" t.boolean "managed", default: false, null: false + t.string "api_create_plan_email_subject" + t.text "api_create_plan_email_body" t.index ["language_id"], name: "fk_rails_5640112cab" t.index ["region_id"], name: "fk_rails_5a6adf6bab" end @@ -253,6 +309,7 @@ t.integer "org_id" t.integer "funder_id" t.integer "grant_id" + t.integer "api_client_id" t.datetime "start_date" t.datetime "end_date" t.boolean "ethical_issues" @@ -263,8 +320,9 @@ t.index ["funder_id"], name: "index_plans_on_funder_id" t.index ["grant_id"], name: "index_plans_on_grant_id" t.index ["org_id"], name: "index_plans_on_org_id" + t.index ["research_domain_id"], name: "index_plans_on_fos_id" t.index ["template_id"], name: "index_plans_on_template_id" - t.index ["research_domain_id"], name: "index_plans_on_research_domain_id" + t.index ["api_client_id"], name: "index_plans_on_api_client_id" end create_table "plans_guidance_groups", id: :integer, force: :cascade do |t| @@ -339,6 +397,42 @@ t.integer "super_region_id" end + create_table "related_identifiers", force: :cascade do |t| + t.bigint "identifier_scheme_id" + t.integer "identifier_type", null: false + t.integer "relation_type", null: false + t.bigint "identifiable_id" + t.string "identifiable_type" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "value", null: false + t.index ["identifiable_id", "identifiable_type", "relation_type"], name: "index_relateds_on_identifiable_and_relation_type" + t.index ["identifier_scheme_id"], name: "index_related_identifiers_on_identifier_scheme_id" + t.index ["identifier_type"], name: "index_related_identifiers_on_identifier_type" + t.index ["relation_type"], name: "index_related_identifiers_on_relation_type" + end + + create_table "repositories", force: :cascade do |t| + t.string "name", null: false + t.text "description", null: false + t.string "homepage" + t.string "contact" + t.string "uri", null: false + t.json "info" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["homepage"], name: "index_repositories_on_homepage" + t.index ["name"], name: "index_repositories_on_name" + t.index ["uri"], name: "index_repositories_on_uri" + end + + create_table "repositories_research_outputs", force: :cascade do |t| + t.bigint "research_output_id" + t.bigint "repository_id" + t.index ["repository_id"], name: "index_repositories_research_outputs_on_repository_id" + t.index ["research_output_id"], name: "index_repositories_research_outputs_on_research_output_id" + end + create_table "research_domains", force: :cascade do |t| t.string "identifier", null: false t.string "label", null: false @@ -364,6 +458,8 @@ t.bigint "byte_size" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.bigint "license_id" + t.index ["license_id"], name: "index_research_outputs_on_license_id" t.index ["output_type"], name: "index_research_outputs_on_output_type" t.index ["plan_id"], name: "index_research_outputs_on_plan_id" end @@ -421,6 +517,19 @@ t.boolean "filtered", default: false end + create_table "subscriptions", force: :cascade do |t| + t.bigint "plan_id" + t.integer "subscription_types", null: false + t.string "callback_uri" + t.bigint "subscriber_id" + t.string "subscriber_type" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.datetime "last_notified" + t.index ["plan_id"], name: "index_subscriptions_on_plan_id" + t.index ["subscriber_id", "subscriber_type", "plan_id"], name: "index_subscribers_on_identifiable_and_plan_id" + end + create_table "templates", id: :integer, force: :cascade do |t| t.string "title" t.text "description" @@ -520,6 +629,8 @@ t.index ["user_id"], name: "index_users_perms_on_user_id" end + add_foreign_key "annotations", "orgs" + add_foreign_key "annotations", "questions" add_foreign_key "answers", "plans" add_foreign_key "answers", "questions" add_foreign_key "answers", "users" @@ -542,6 +653,8 @@ add_foreign_key "question_options", "questions" add_foreign_key "questions", "question_formats" add_foreign_key "questions", "sections" + add_foreign_key "research_domains", "research_domains", column: "parent_id" + add_foreign_key "research_outputs", "licenses" add_foreign_key "roles", "plans" add_foreign_key "roles", "users" add_foreign_key "sections", "phases" diff --git a/lib/tasks/utils/external_api.rake b/lib/tasks/utils/external_api.rake index ac52210f6c..7191d94621 100644 --- a/lib/tasks/utils/external_api.rake +++ b/lib/tasks/utils/external_api.rake @@ -1,6 +1,25 @@ # frozen_string_literal: true namespace :external_api do + desc 'Fetch the latest RDA Metadata Standards' + task load_rdamsc_standards: :environment do + p 'Fetching the latest RDAMSC metadata standards and updating the metadata_standards table' + ExternalApis::RdamscService.fetch_metadata_standards + end + + desc 'Load Repositories from re3data' + task load_re3data_repos: :environment do + p 'Fetching the latest re3data repository metadata and updating the repositories table' + p 'This can take in excess of 10 minutes to complete ...' + ExternalApis::Re3dataService.fetch + end + + desc 'Load Licenses from SPDX' + task load_spdx_licenses: :environment do + p 'Fetching the latest SPDX license metadata and updating the licenses table' + ExternalApis::SpdxService.fetch + end + desc 'Seed the Research Domain table with Field of Science categories' task add_field_of_science_to_research_domains: :environment do # TODO: If we can identify an external API authority for this information we should switch diff --git a/spec/factories/licenses.rb b/spec/factories/licenses.rb new file mode 100644 index 0000000000..3564d800c5 --- /dev/null +++ b/spec/factories/licenses.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: licenses +# +# id :bigint not null, primary key +# deprecated :boolean default(FALSE) +# identifier :string not null +# name :string not null +# osi_approved :boolean default(FALSE) +# uri :string not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_license_on_identifier_and_criteria (identifier,osi_approved,deprecated) +# index_licenses_on_identifier (identifier) +# index_licenses_on_uri (uri) +# +FactoryBot.define do + factory :license do + name { Faker::Lorem.sentence } + identifier { Faker::Music::PearlJam.unique.song.upcase } + uri { Faker::Internet.unique.url } + osi_approved { [true, false].sample } + deprecated { [true, false].sample } + end +end diff --git a/spec/factories/metadata_standards.rb b/spec/factories/metadata_standards.rb new file mode 100644 index 0000000000..ca8136b37e --- /dev/null +++ b/spec/factories/metadata_standards.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: metadata_standards +# +# id :bigint not null, primary key +# description :text +# locations :json +# related_entities :json +# title :string +# uri :string +# created_at :datetime not null +# updated_at :datetime not null +# rdamsc_id :string +# +FactoryBot.define do + factory :metadata_standard do + description { Faker::Lorem.paragraph } + locations do + [ + { type: %w[website document RDFS].sample, url: Faker::Internet.unique.url }, + { type: %w[website document RDFS].sample, url: Faker::Internet.unique.url } + ] + end + related_entities do + [ + { + role: %w[user tool child scheme].sample, + id: "msc:#{Faker::Number.unique.number(digits: 2)}" + }, + { + role: %w[user tool child scheme].sample, + id: "msc:#{Faker::Number.unique.number(digits: 2)}" + } + ] + end + title { Faker::Lorem.unique.sentence } + uri { Faker::Internet.unique.url } + rdamsc_id { "msc:#{Faker::Number.unique.number(digits: 2)}" } + end +end diff --git a/spec/factories/orgs.rb b/spec/factories/orgs.rb index 04515cc482..a9f15f74b5 100644 --- a/spec/factories/orgs.rb +++ b/spec/factories/orgs.rb @@ -35,7 +35,7 @@ factory :org do name { Faker::Company.unique.name } links { { 'org' => [] } } - abbreviation { SecureRandom.hex(4) } + abbreviation { SecureRandom.hex(6) } feedback_enabled { false } region { Region.first || create(:region) } language { Language.default } diff --git a/spec/factories/plans.rb b/spec/factories/plans.rb index 36cdd56af2..6361c98bc1 100644 --- a/spec/factories/plans.rb +++ b/spec/factories/plans.rb @@ -21,6 +21,7 @@ # org_id :integer # funder_id :integer # grant_id :integer +# api_client_id :integer # research_domain_id :bigint # # Indexes @@ -34,6 +35,7 @@ # # fk_rails_... (template_id => templates.id) # fk_rails_... (org_id => orgs.id) +# fk_rails_... (api_client_id => api_clients.id) # fk_rails_... (research_domain_id => research_domains.id) # diff --git a/spec/factories/repositories.rb b/spec/factories/repositories.rb new file mode 100644 index 0000000000..922a8a47b2 --- /dev/null +++ b/spec/factories/repositories.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: repositories +# +# id :bigint not null, primary key +# contact :string +# description :text not null +# info :json +# name :string not null +# homepage :string +# created_at :datetime not null +# updated_at :datetime not null +# uri :string not null +# +# Indexes +# +# index_repositories_on_name (name) +# index_repositories_on_url (homepage) +# index_repositories_on_url (uri) +# +FactoryBot.define do + factory :repository do + name { Faker::Music::PearlJam.song } + description { Faker::Lorem.paragraph } + homepage { Faker::Internet.unique.url } + uri { Faker::Internet.unique.url } + contact { Faker::Internet.email } + info do + { + types: [%w[disciplinary institutional other].sample], + access: %w[closed open restricted].sample, + keywords: [Faker::Lorem.word], + policies: [{ url: Faker::Internet.url, name: Faker::Music::PearlJam.album }], + subjects: ["#{Faker::Number.number(digits: 2)} #{Faker::Lorem.sentence}"], + pid_system: %w[ARK DOI handle].sample, + upload_types: [{ type: Faker::Lorem.word, restriction: Faker::Lorem.word }], + provider_types: [%w[dataProvider serviceProvider].sample] + } + end + end +end diff --git a/spec/factories/research_outputs.rb b/spec/factories/research_outputs.rb index 807ecd8ad4..4f62a881a1 100644 --- a/spec/factories/research_outputs.rb +++ b/spec/factories/research_outputs.rb @@ -4,31 +4,33 @@ # # Table name: research_outputs # -# id :bigint not null, primary key -# abbreviation :string -# access :integer default(0), not null -# byte_size :bigint -# description :text +# id :bigint(8) not null, primary key +# abbreviation :string(255) +# access :integer default("open"), not null +# byte_size :bigint(8) +# description :text(65535) # display_order :integer -# is_default :boolean default("false") -# output_type :integer default(3), not null -# output_type_description :string +# is_default :boolean +# output_type :integer default("dataset"), not null +# output_type_description :string(255) # personal_data :boolean # release_date :datetime # sensitive_data :boolean -# title :string not null +# title :string(255) not null # created_at :datetime not null # updated_at :datetime not null -# mime_type_id :integer +# license_id :bigint(8) # plan_id :integer # # Indexes # +# index_research_outputs_on_license_id (license_id) # index_research_outputs_on_output_type (output_type) # index_research_outputs_on_plan_id (plan_id) # FactoryBot.define do factory :research_output do + license abbreviation { Faker::Lorem.unique.word } access { ResearchOutput.accesses.keys.sample } byte_size { Faker::Number.number } @@ -42,13 +44,15 @@ sensitive_data { [nil, true, false].sample } title { Faker::Music::PearlJam.song } - trait :complete do - after(:create) do |research_output| - # add a license identifier - # add a repository identifier - # add a metadata_standard identifier - # add a resource_type identifier - end + transient do + repositories_count { 1 } + metadata_standards_count { 1 } + end + + after(:create) do |research_output, evaluator| + research_output.repositories = create_list(:repository, evaluator.repositories_count) + research_output.metadata_standards = create_list(:metadata_standard, + evaluator.metadata_standards_count) end end end diff --git a/spec/features/modal_search_spec.rb b/spec/features/modal_search_spec.rb new file mode 100644 index 0000000000..9bc7523aac --- /dev/null +++ b/spec/features/modal_search_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.feature 'ModalSearchDialog', type: :feature do + include Webmocks + + before(:each) do + stub_openaire + + @model = create(:repository) + @template = create(:template) + @plan = create(:plan, :creator, template: @template) + @user = @plan.owner + sign_in_as_user(@user) + + Rails.configuration.x.madmp.enable_research_outputs = true + Rails.configuration.x.madmp.enable_repository_selection = true + + click_link @plan.title + click_link 'Research Outputs' + click_link 'Add a research output' + end + + it 'Modal search opens and closes and allows user to search, select and remove items', :js do + # Open the modal + click_button 'Add a repository' + expect(page).to have_text('Repository search') + + within('#modal-search-repositories') do + # Search for the Repository + fill_in 'research_output_search_term', with: @model.name + click_button 'Apply filter(s)' + expect(page).to have_text(@model.description) + + # Select the repository and make sure it no longer appears in the search results + click_link 'Select' + expect(page).not_to have_text(@model.description) + + # Close the modal + click_button 'Close' + end + + # Verify that the selection was added to the main page's dom + expect(page).not_to have_text('Repository search') + expect(page).to have_text(@model.description) + # Verify that we can remove the selection + click_link 'Remove' + expect(page).not_to have_text(@model.description) + end +end diff --git a/spec/models/license_spec.rb b/spec/models/license_spec.rb new file mode 100644 index 0000000000..223ed2f671 --- /dev/null +++ b/spec/models/license_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe License do + context 'associations' do + it { is_expected.to have_many :research_outputs } + end + + context 'scopes' do + describe '#selectable' do + before(:each) do + @license = create(:license, deprecated: false) + @deprecated = create(:license, deprecated: true) + end + + it 'does not include deprecated licenses' do + expect(described_class.selectable.include?(@deprecated)).to eql(false) + end + it 'includes non-depracated licenses' do + expect(described_class.selectable.include?(@license)).to eql(true) + end + end + + describe '#preferred' do + before(:each) do + @preferred_license = create(:license, deprecated: false) + @non_preferred_license = create(:license, deprecated: false) + + @preferred_oldest = create(:license, deprecated: false) + @preferred_older = create(:license, identifier: "#{@preferred_oldest.identifier}-1.0", + deprecated: false) + @preferred_latest = create(:license, identifier: "#{@preferred_oldest.identifier}-1.1", + deprecated: false) + + Rails.configuration.x.madmp.preferred_licenses = [ + @preferred_license.identifier, + "#{@preferred_oldest.identifier}-%{latest}" + ] + end + + it 'calls :selectable if no preferences are defined in the app config' do + Rails.configuration.x.madmp.preferred_licenses = nil + described_class.expects(:selectable).returns([@license]) + described_class.preferred + end + it 'does not include non-preferred licenses' do + expect(described_class.preferred.include?(@non_preferred_license)).to eql(false) + end + it 'includes preferred licenses' do + expect(described_class.preferred.include?(@preferred_license)).to eql(true) + end + it 'includes the latest version of a preferred licenses' do + expect(described_class.preferred.include?(@preferred_latest)).to eql(true) + expect(described_class.preferred.include?(@preferred_oldest)).to eql(false) + expect(described_class.preferred.include?(@preferred_older)).to eql(false) + end + end + end +end diff --git a/spec/models/metadata_standard_spec.rb b/spec/models/metadata_standard_spec.rb new file mode 100644 index 0000000000..5d48541c10 --- /dev/null +++ b/spec/models/metadata_standard_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe MetadataStandard do + context 'associations' do + it { is_expected.to have_and_belong_to_many :research_outputs } + end + + context 'scopes' do + before(:each) do + @name_part = 'Foobar' + @by_title = create(:metadata_standard, title: [Faker::Lorem.sentence, @name_part].join(' ')) + desc = [@name_part, Faker::Lorem.paragraph].join(' ') + @by_description = create(:metadata_standard, description: desc) + end + + it ':search returns the expected records' do + results = described_class.search(@name_part) + expect(results.include?(@by_title)).to eql(true) + expect(results.include?(@by_description)).to eql(true) + + results = described_class.search('Zzzzzz') + expect(results.include?(@by_title)).to eql(false) + expect(results.include?(@by_description)).to eql(false) + end + end +end diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb new file mode 100644 index 0000000000..09f76fc861 --- /dev/null +++ b/spec/models/repository_spec.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: repositories +# +# id :integer not null, primary key +# name :string not null +# description :text +# url :string +# contact :string +# info :json +# created_at :datetime +# updated_at :datetime +# +# Indexes +# +# index_repositories_on_name (name) +# index_repositories_on_url (url) +# + +require 'rails_helper' + +describe Repository do + context 'associations' do + it { is_expected.to have_and_belong_to_many :research_outputs } + end + + context 'scopes' do + before(:each) do + @types = %w[Armadillo Barracuda] + @subjects = %w[Capybara Dingo] + @keywords = %w[Elephant Falcon] + + @never_found = create(:repository, name: 'foo', info: { types: [@types.last], + subjects: [@subjects.last], + keywords: [@keywords.last] }) + + @by_type = create(:repository, info: { types: [@types.first], + subjects: [@subjects.last], + keywords: [@keywords.last] }) + @by_subject = create(:repository, info: { types: [@types.last], + subjects: [@subjects.first], + keywords: [@keywords.last] }) + @by_facet = create(:repository, info: { types: [@types.last], + subjects: [@subjects.last], + keywords: [@keywords.first] }) + end + + describe '#by_type' do + it 'returns the expected repositories' do + results = described_class.by_type(@types.first) + expect(results.include?(@never_found)).to eql(false) + expect(results.include?(@by_type)).to eql(true) + expect(results.include?(@by_subject)).to eql(false) + expect(results.include?(@by_facet)).to eql(false) + end + end + + describe '#by_subject' do + it 'returns the expected repositories' do + results = described_class.by_subject(@subjects.first) + expect(results.include?(@never_found)).to eql(false) + expect(results.include?(@by_type)).to eql(false) + expect(results.include?(@by_subject)).to eql(true) + expect(results.include?(@by_facet)).to eql(false) + end + end + + describe '#by_facet' do + it 'returns the expected repositories' do + results = described_class.by_facet(@keywords.first) + expect(results.include?(@never_found)).to eql(false) + expect(results.include?(@by_type)).to eql(false) + expect(results.include?(@by_subject)).to eql(false) + expect(results.include?(@by_facet)).to eql(true) + end + end + + describe '#search' do + it 'returns repositories with keywords like the search term' do + results = described_class.search(@keywords.first[1..3]) + expect(results.include?(@never_found)).to eql(false) + expect(results.include?(@by_type)).to eql(false) + expect(results.include?(@by_subject)).to eql(false) + expect(results.include?(@by_facet)).to eql(true) + end + it 'returns repositories with subjects like the search term' do + results = described_class.search(@by_subject.name[1..@by_subject.name.length - 1]) + expect(results.include?(@never_found)).to eql(false) + expect(results.include?(@by_type)).to eql(false) + expect(results.include?(@by_subject)).to eql(true) + end + it 'returns repositories with name like the search term' do + repo = create(:repository, name: [Faker::Lorem.word, @by_subject.name].join(' ')) + results = described_class.search(@by_subject.name[1..@by_subject.name.length - 1]) + expect(results.include?(@never_found)).to eql(false) + expect(results.include?(repo)).to eql(true) + end + end + end +end diff --git a/spec/models/research_output_spec.rb b/spec/models/research_output_spec.rb index bdccc75518..f09baf1d1e 100644 --- a/spec/models/research_output_spec.rb +++ b/spec/models/research_output_spec.rb @@ -4,7 +4,7 @@ RSpec.describe ResearchOutput, type: :model do context 'associations' do - it { is_expected.to belong_to(:plan).optional } + it { is_expected.to belong_to(:plan).optional.touch(true) } end context 'validations' do @@ -18,8 +18,16 @@ it { is_expected.to validate_presence_of(:access) } it { is_expected.to validate_presence_of(:title) } - it { expect(@subject).to validate_uniqueness_of(:title).scoped_to(:plan_id) } - it { expect(@subject).to validate_uniqueness_of(:abbreviation).scoped_to(:plan_id) } + it { + expect(@subject).to validate_uniqueness_of(:title).case_insensitive + .scoped_to(:plan_id) + .with_message('must be unique') + } + it { + expect(@subject).to validate_uniqueness_of(:abbreviation).case_insensitive + .scoped_to(:plan_id) + .with_message('must be unique') + } it "requires :output_type_description if :output_type is 'other'" do @subject.other! @@ -32,12 +40,11 @@ end it 'factory builds a valid model' do expect(build(:research_output).valid?).to eql(true) - expect(build(:research_output, :complete).valid?).to eql(true) end describe 'cascading deletes' do it 'does not delete associated plan' do - model = create(:research_output, :complete, plan: create(:plan)) + model = create(:research_output, plan: create(:plan)) plan = model.plan model.destroy expect(Plan.last).to eql(plan) diff --git a/spec/presenters/api/v1/funding_presenter_spec.rb b/spec/presenters/api/v1/funding_presenter_spec.rb index 181e46b830..87c3cbeda8 100644 --- a/spec/presenters/api/v1/funding_presenter_spec.rb +++ b/spec/presenters/api/v1/funding_presenter_spec.rb @@ -1,24 +1,27 @@ # frozen_string_literal: true -module Api - module V1 - class FundingPresenter - class << self - # If the plan has a grant number then it has been awarded/granted - # otherwise it is 'planned' - def status(plan:) - return 'planned' unless plan.present? +require 'rails_helper' - case plan.funding_status - when 'funded' - 'granted' - when 'denied' - 'rejected' - else - 'planned' - end - end - end +RSpec.describe Api::V1::FundingPresenter do + describe '#status(plan:)' do + it 'returns `planned` if the plan is nil' do + expect(described_class.status(plan: nil)).to eql('planned') + end + it 'returns `planned` if the :funding_status is nil' do + plan = build(:plan, funding_status: nil) + expect(described_class.status(plan: plan)).to eql('planned') + end + it "returns `granted` if the :funding_status is 'funded'" do + plan = build(:plan, funding_status: 'funded') + expect(described_class.status(plan: plan)).to eql('granted') + end + it "returns `rejected` if the :funding_status is 'denied'" do + plan = build(:plan, funding_status: 'denied') + expect(described_class.status(plan: plan)).to eql('rejected') + end + it "returns `planned` if the :funding_status is 'planned'" do + plan = build(:plan, funding_status: 'planned') + expect(described_class.status(plan: plan)).to eql('planned') end end end diff --git a/spec/presenters/research_output_presenter_spec.rb b/spec/presenters/research_output_presenter_spec.rb new file mode 100644 index 0000000000..43b84ec311 --- /dev/null +++ b/spec/presenters/research_output_presenter_spec.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ResearchOutputPresenter do + before(:each) do + @research_output = create(:research_output, plan: create(:plan)) + @presenter = described_class.new(research_output: @research_output) + end + + describe ':selectable_output_types' do + it 'returns the output types' do + expect(@presenter.selectable_output_types.any?).to eql(true) + end + it "packages the output types for a selectbox - [['Audiovisual', 'audiovisual']]" do + sample = @presenter.selectable_output_types.first + expect(sample.length).to eql(2) + expect(sample[0].scan(/^[a-zA-Z\s]*$/).any?).to eql(true) + expect(sample[1].scan(/^[a-z]*$/).any?).to eql(true) + expect(sample[0].underscore).to eql(sample[1]) + expect(ResearchOutput.output_types[sample[1]].present?).to eql(true) + end + end + + describe ':selectable_access_types' do + it 'returns the output types' do + expect(@presenter.selectable_access_types.any?).to eql(true) + end + it "packages the output types for a selectbox - [['Open', 'open']]" do + sample = @presenter.selectable_access_types.first + expect(sample.length).to eql(2) + expect(sample[0].scan(/^[a-zA-Z\s]*$/).any?).to eql(true) + expect(sample[1].scan(/^[a-z]*$/).any?).to eql(true) + expect(sample[0].underscore).to eql(sample[1]) + expect(ResearchOutput.accesses[sample[1]].present?).to eql(true) + end + end + + describe ':selectable_size_units' do + it 'returns the output types' do + expect(@presenter.selectable_size_units.any?).to eql(true) + end + it "packages the output types for a selectbox - [['MB', 'mb']]" do + sample = @presenter.selectable_size_units.first + expect(sample.length).to eql(2) + expect(sample[0].scan(/^[a-zA-Z\s]*$/).any?).to eql(true) + expect(sample[1].scan(/^[a-z]*$/).any?).to eql(true) + expect(sample[0].downcase).to eql(sample[1]) + end + end + + describe ':converted_file_size(size:)' do + it 'returns an zero MB if size is not present' do + expect(@presenter.converted_file_size(size: nil)).to eql({ size: nil, unit: 'mb' }) + end + it 'returns an zero MB if size is not a number' do + expect(@presenter.converted_file_size(size: 'foo')).to eql({ size: nil, unit: 'mb' }) + end + it 'returns an zero MB if size is not positive' do + expect(@presenter.converted_file_size(size: -1)).to eql({ size: nil, unit: 'mb' }) + end + it 'can handle bytes' do + expect(@presenter.converted_file_size(size: 100)).to eql({ size: 100, unit: '' }) + end + it 'can handle megabytes' do + expect(@presenter.converted_file_size(size: 1.megabytes)).to eql({ size: 1, unit: 'mb' }) + end + it 'can handle gigabytes' do + expect(@presenter.converted_file_size(size: 1.gigabytes)).to eql({ size: 1, unit: 'gb' }) + end + it 'can handle terabytes' do + expect(@presenter.converted_file_size(size: 1.terabytes)).to eql({ size: 1, unit: 'tb' }) + end + it 'can handle petabytes' do + expect(@presenter.converted_file_size(size: 1.petabytes)).to eql({ size: 1, unit: 'pb' }) + end + end + + describe ':display_name' do + it 'returns an empty string unless if we do not have a ResearchOutput' do + presenter = described_class.new(research_output: build(:org)) + expect(presenter.display_name).to eql('') + end + it 'does not trim names that are <= 50 characters' do + presenter = described_class.new(research_output: build(:research_output, title: 'a' * 49)) + expect(presenter.display_name).to eql('a' * 49) + end + it 'does not trims names that are > 50 characters' do + presenter = described_class.new(research_output: build(:research_output, title: 'a' * 51)) + expect(presenter.display_name).to eql("#{'a' * 50} ...") + end + end + + describe ':display_type' do + it 'returns an empty string unless if we do not have a ResearchOutput' do + presenter = described_class.new(research_output: build(:org)) + expect(presenter.display_type).to eql('') + end + it "returns the user's description if the output_type is other" do + research_output = build(:research_output, output_type: 'other', + output_type_description: 'foo') + presenter = described_class.new(research_output: research_output) + expect(presenter.display_type).to eql('foo') + end + it 'returns the humanized version of the output_type' do + presenter = described_class.new(research_output: build(:research_output, output_type: :image)) + expect(presenter.display_type).to eql('Image') + end + end + + describe ':display_repository' do + before(:each) do + @research_output.repositories.clear + end + it "returns ['None specified'] if not repositories are assigned" do + presenter = described_class.new(research_output: @research_output) + expect(presenter.display_repository).to eql(['None specified']) + end + it 'returns an array of names when there is only one repository' do + repo = build(:repository) + @research_output.repositories << repo + presenter = described_class.new(research_output: @research_output) + expect(presenter.display_repository).to eql([repo.name]) + end + it 'returns an array of names when there are multiple repositories' do + repos = [build(:repository), build(:repository)] + @research_output.repositories << repos + presenter = described_class.new(research_output: @research_output) + expect(presenter.display_repository).to eql(repos.collect(&:name)) + end + end + + describe ':display_access' do + it "returns 'Unspecified' if :access has not been defined" do + presenter = described_class.new(research_output: build(:research_output, access: nil)) + expect(presenter.display_access).to eql('Unspecified') + end + it 'returns a humanized version of the :access enum selection' do + presenter = described_class.new(research_output: build(:research_output, access: :open)) + expect(presenter.display_access).to eql('Open') + end + end + + describe ':display_release' do + it "returns 'Unspecified' if :access has not been defined" do + presenter = described_class.new(research_output: build(:research_output, release_date: nil)) + expect(presenter.display_release).to eql('Unspecified') + end + it 'returns a the release_date as a Date' do + now = Time.now + presenter = described_class.new(research_output: build(:research_output, release_date: now)) + expect(presenter.display_release.is_a?(Date)).to eql(true) + end + end + + context 'class methods' do + describe ':selectable_subjects' do + it 'returns subjects' do + expect(described_class.selectable_subjects.any?).to eql(true) + end + it "packages the subjects for a selectbox - [['Biology', '21 Biology']]" do + sample = described_class.selectable_subjects.first + expect(sample.length).to eql(2) + expect(sample[0].scan(/^[a-zA-Z\s,]*$/).any?).to eql(true) + expect(sample[1].scan(/^[0-9]+\s[a-zA-Z\s,]*$/).any?).to eql(true) + expect(sample[1].ends_with?(sample[0])).to eql(true) + end + end + + describe ':selectable_repository_types' do + it 'returns repository types' do + expect(described_class.selectable_repository_types.any?).to eql(true) + end + it "packages the repo types for a selectbox - [['Discipline specific', 'disciplinary']]" do + sample = described_class.selectable_repository_types.first + expect(sample.length).to eql(2) + expect(sample[0].scan(/^[A-Z]{1}[a-z\s()]*$/).any?).to eql(true) + expect(sample[1].scan(/^[a-z]*$/).any?).to eql(true) + end + end + end +end diff --git a/spec/services/api/v1/persistence_service_spec.rb b/spec/services/api/v1/persistence_service_spec.rb index d39b65c852..306f04f555 100644 --- a/spec/services/api/v1/persistence_service_spec.rb +++ b/spec/services/api/v1/persistence_service_spec.rb @@ -184,7 +184,7 @@ expect(results.length).to eql(0) end it 'leaves different :contributors as-is' do - @plan.contributors << build(:contributor, name: Faker::Movies::StarWars.character, + @plan.contributors << build(:contributor, name: Faker::Movies::StarWars.unique.character, email: Faker::Internet.unique.email) results = described_class.send(:deduplicate_contributors, contributors: @plan.contributors) expect(results.length).to eql(2) diff --git a/spec/services/external_apis/rdamsc_service_spec.rb b/spec/services/external_apis/rdamsc_service_spec.rb new file mode 100644 index 0000000000..0f134f886c --- /dev/null +++ b/spec/services/external_apis/rdamsc_service_spec.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ExternalApis::RdamscService do + include Webmocks + + before(:each) do + MetadataStandard.all.destroy_all + + @rdams_results = { + apiVersion: '2.0.0', + data: { + currentItemCount: Faker::Number.number(digits: 2), + items: [ + { + description: Faker::Lorem.paragraph, + keywords: [ + Faker::Internet.unique.url + ], + locations: [ + { type: %w[document website].sample, url: Faker::Internet.unique.url } + ], + mscid: "msc:m#{Faker::Number.number(digits: 2)}", + relatedEntities: [ + { id: "msc:m#{Faker::Number.number(digits: 2)}", role: %w[scheme child].sample } + ], + slug: SecureRandom.uuid, + title: Faker::Lorem.sentence, + uri: Faker::Internet.unique.url + } + ] + } + } + + stub_rdamsc_service(successful: true, response_body: @rdams_results.to_json) + end + + describe ':fetch_metadata_standards' do + it 'calls :query_schemes' do + described_class.expects(:query_schemes).returns(nil) + expect(described_class.fetch_metadata_standards).to eql(nil) + end + end + + context 'private methods' do + describe ':query_api(path:)' do + it 'returns nil if path is not present' do + expect(described_class.send(:query_api, path: nil)).to eql(nil) + end + it 'calls the error handler if an HTTP 200 is not received from the SPDX API' do + stub_rdamsc_service(successful: false) + described_class.expects(:handle_http_failure) + expect(described_class.send(:query_api, path: Faker::Lorem.word)).to eql(nil) + end + it 'logs an error if the response was invalid JSON' do + JSON.expects(:parse).raises(JSON::ParserError.new) + described_class.expects(:log_error) + expect(described_class.send(:query_api, path: Faker::Lorem.word)).to eql(nil) + end + it 'reuturns the array of response body as JSON' do + expected = JSON.parse(@rdams_results.to_json) + expect(described_class.send(:query_api, path: Faker::Lorem.word)).to eql(expected) + end + end + + describe ':query_schemes(path:)' do + before(:each) do + @path = Faker::Internet.unique.url + end + it 'returns false if the initial query returned no results' do + described_class.expects(:query_api).with(path: @path).returns(nil) + expect(described_class.send(:query_schemes, path: @path)).to eql(false) + end + it 'calls :process_scheme_entries if the query returned results' do + described_class.expects(:query_api).with(path: @path).returns(@rdams_results) + described_class.expects(:process_scheme_entries) + described_class.send(:query_schemes, path: @path) + end + it "recursively calls itself while a 'nextLink' is provided in the query results" do + hash = @rdams_results + hash[:data][:nextLink] = "#{@path}/next" + described_class.expects(:query_api) + .with(path: @path).returns(hash.with_indifferent_access) + described_class.expects(:query_api) + .with(path: hash[:data][:nextLink]).returns(@rdams_results) + described_class.expects(:process_scheme_entries).twice + described_class.send(:query_schemes, path: @path) + end + end + + describe ':process_scheme_entries(json:)' do + it 'returns false if json is not present' do + expect(described_class.send(:process_scheme_entries, json: nil)).to eql(false) + end + it 'returns false if json does not contain :data not present' do + expect(described_class.send(:process_scheme_entries, json: { foo: 'bar' })).to eql(false) + end + it 'returns false if json[:data] does not contain :items present' do + json = { data: { items: [] } } + expect(described_class.send(:process_scheme_entries, json: json)).to eql(false) + end + it 'updates the MetadataStandard if it already exists' do + hash = @rdams_results[:data][:items].first + standard = create(:metadata_standard, uri: hash[:uri], + title: hash[:title]) + + expect(described_class.send(:process_scheme_entries, + json: JSON.parse(@rdams_results.to_json))) + result = MetadataStandard.last + expect(result.id).to eql(standard.id) + expect(result.title).to eql(hash[:title]) + expect(result.uri).to eql(hash[:uri]) + + expect(result.description).to eql(hash[:description]) + expect(result.rdamsc_id).to eql(hash[:mscid]) + expect(result.locations).to eql(JSON.parse(hash[:locations].to_json)) + expect(result.related_entities).to eql(JSON.parse(hash[:relatedEntities].to_json)) + end + it 'creates a new MetadataStandard' do + hash = @rdams_results[:data][:items].first + + expect(described_class.send(:process_scheme_entries, + json: JSON.parse(@rdams_results.to_json))) + result = MetadataStandard.last + expect(result.title).to eql(hash[:title]) + expect(result.description).to eql(hash[:description]) + expect(result.rdamsc_id).to eql(hash[:mscid]) + expect(result.uri).to eql(hash[:uri]) + expect(result.locations).to eql(JSON.parse(hash[:locations].to_json)) + expect(result.related_entities).to eql(JSON.parse(hash[:relatedEntities].to_json)) + end + end + end +end diff --git a/spec/services/external_apis/re3data_service_spec.rb b/spec/services/external_apis/re3data_service_spec.rb new file mode 100644 index 0000000000..af1d38f29b --- /dev/null +++ b/spec/services/external_apis/re3data_service_spec.rb @@ -0,0 +1,317 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ExternalApis::Re3dataService do + before(:each) do + @repo_id = "r3d#{Faker::Number.number(digits: 6)}" + @headers = described_class.headers + @repositories_path = "#{described_class.api_base_url}#{described_class.list_path}" + path = "#{described_class.api_base_url}#{described_class.repository_path}#{@repo_id}" + @repository_path = URI(path) + + @repositories_results = <<-XML + + + + #{@repo_id} + #{Faker::Company.name} + + + + XML + @repository_result = <<-XML + + + + + #{@repo_id} + #{Faker::Lorem.word.upcase} + #{Faker::Internet.url} + #{Faker::Lorem.word}:#{Faker::Number.number(digits: 5)} + #{Faker::Lorem.sentence} + #{Faker::Internet.email} + #{%w[disciplinary institutional other].sample} + #{Faker::Number.number(digits: 4)} data packages + 2021 + + eng + 1 Humanities and Social Sciences + #{Faker::Internet.url} + Plain text + dataProvider + #{Faker::Lorem.word} + + #{Faker::Company.name} + #{Faker::Lorem.word.upcase} + USA + general + non-profit + #{Faker::Internet.url} + 2021 + + + + + #{Faker::Lorem.sentence} + #{Faker::Internet.url} + + #{%w[open restricted closed].sample} + + #{Faker::Lorem.word} + #{Faker::Internet.url} + + #{Faker::Lorem.word} + + #{Faker::Lorem.word} + #{Faker::Internet.url} + + + #{Faker::Lorem.word} + #{Faker::Lorem.word} + + + #{Faker::Lorem.word} + #{Faker::Internet.url} + + #{Faker::Lorem.word} + #{%w[no yes].sample} + #{Faker::Internet.url} + #{%w[ARK DOI handle].sample} + #{Faker::Internet.url} + #{Faker::Lorem.word.upcase} + #{%w[no yes].sample} + #{%w[no yes].sample} + + #{Faker::Lorem.sentence} + #{Faker::Internet.url} + + #{Faker::Lorem.sentence} + 2021-02-03 + 2021-02-03 + + + XML + end + + describe '#fetch' do + context '#fetch' do + it 'returns an empty array if re3data did not return a repository list' do + described_class.expects(:query_re3data).returns(nil) + expect(described_class.fetch).to eql([]) + end + it 'fetches individual repository data' do + described_class.expects(:query_re3data) + .returns(Nokogiri::XML(@repositories_results, nil, 'utf8')) + described_class.expects(:query_re3data_repository).at_least(1) + described_class.fetch + end + it 'processes the repository data' do + described_class.expects(:query_re3data) + .returns(Nokogiri::XML(@repositories_results, nil, 'utf8')) + described_class.expects(:query_re3data_repository) + .returns(Nokogiri::XML(@repository_result, nil, 'utf8')) + described_class.expects(:process_repository).at_least(1) + described_class.fetch + end + end + end + + context 'private methods' do + describe '#query_re3data' do + it 'calls the handle_http_failure method if a non 200 response is received' do + stub_request(:get, @repositories_path).with(headers: @headers) + .to_return(status: 403, body: '', headers: {}) + described_class.expects(:handle_http_failure).at_least(1) + expect(described_class.send(:query_re3data)).to eql(nil) + end + it 'returns the response body as XML' do + stub_request(:get, @repositories_path).with(headers: @headers) + .to_return( + status: 200, + body: @repositories_results, + headers: {} + ) + expected = Nokogiri::XML(@repositories_results, nil, 'utf8').text + expect(described_class.send(:query_re3data).text).to eql(expected) + end + end + + describe '#query_re3data_repository(repo_id:)' do + it 'returns an empty array if term is blank' do + expect(described_class.send(:query_re3data_repository, repo_id: nil)).to eql([]) + end + it 'calls the handle_http_failure method if a non 200 response is received' do + stub_request(:get, @repository_path).with(headers: @headers) + .to_return(status: 403, body: '', headers: {}) + described_class.expects(:handle_http_failure).at_least(1) + expect(described_class.send(:query_re3data_repository, repo_id: @repo_id)).to eql([]) + end + it 'returns the response body as JSON' do + stub_request(:get, @repository_path).with(headers: @headers) + .to_return( + status: 200, + body: @repository_result, + headers: {} + ) + expected = Nokogiri::XML(@repository_result, nil, 'utf8').text + result = described_class.send(:query_re3data_repository, repo_id: @repo_id).text + expect(result).to eql(expected) + end + end + + describe '#process_repository(id:, node:)' do + before(:each) do + @node = Nokogiri::XML(@repository_result, nil, 'utf8') + @repo = @node.xpath('//r3d:re3data//r3d:repository').first + end + it 'returns nil if :id is not present' do + expect(described_class.send(:process_repository, id: nil, node: @repo)).to eql(nil) + end + it 'returns nil if :node is not present' do + expect(described_class.send(:process_repository, id: @repo_id, node: nil)).to eql(nil) + end + it 'finds an existing Repository by its identifier' do + repo = create(:repository, uri: @repo_id) + expect(described_class.send(:process_repository, id: @repo_id, node: @repo)).to eql(repo) + end + it 'finds an existing Repository by its homepage' do + repo = create(:repository, homepage: @repo.xpath('//r3d:repositoryURL')&.text) + expect(described_class.send(:process_repository, id: @repo_id, node: @repo)).to eql(repo) + end + it 'creates a new Repository' do + repo = described_class.send(:process_repository, id: @repo_id, node: @repo) + expect(repo.new_record?).to eql(false) + expect(repo.name).to eql(@repo.xpath('//r3d:repositoryName')&.text) + end + it 'attaches the identifier to the Repository (if it is not already defined' do + repo = described_class.send(:process_repository, id: @repo_id, node: @repo) + expect(repo.uri.ends_with?(@repo_id)).to eql(true) + end + end + + describe '#parse_repository(repo:, node:)' do + before(:each) do + doc = Nokogiri::XML(@repository_result, nil, 'utf8') + @node = doc.xpath('//r3d:re3data//r3d:repository').first + @repo = create(:repository, name: @node.xpath('//r3d:repositoryName')&.text) + end + it 'returns nil if :repo is not present' do + expect(described_class.send(:parse_repository, repo: nil, node: @node)).to eql(nil) + end + it 'returns nil if :node is not present' do + expect(described_class.send(:parse_repository, repo: @repo, node: nil)).to eql(nil) + end + it 'updates the :description' do + described_class.send(:parse_repository, repo: @repo, node: @node) + expect(@repo.description).to eql(@node.xpath('//r3d:description')&.text) + end + it 'updates the :homepage' do + described_class.send(:parse_repository, repo: @repo, node: @node) + expect(@repo.homepage).to eql(@node.xpath('//r3d:repositoryURL')&.text) + end + it 'updates the :contact' do + described_class.send(:parse_repository, repo: @repo, node: @node) + expect(@repo.contact).to eql(@node.xpath('//r3d:repositoryContact')&.text) + end + it 'updates the :info' do + described_class.send(:parse_repository, repo: @repo, node: @node) + expect(@repo.info.present?).to eql(true) + end + context ':info JSON content' do + before(:each) do + policies = @node.xpath('//r3d:policy').map do |node| + described_class.send(:parse_policy, node: node) + end + upload_types = @node.xpath('//r3d:dataUpload').map do |node| + described_class.send(:parse_upload, node: node) + end + + @expected = { + types: @node.xpath('//r3d:type').map(&:text), + subjects: @node.xpath('//r3d:subject').map(&:text), + provider_types: @node.xpath('//r3d:providerType').map(&:text), + keywords: @node.xpath('//r3d:keyword').map(&:text), + access: @node.xpath('//r3d:databaseAccess//r3d:databaseAccessType')&.text, + pid_system: @node.xpath('//r3d:pidSystem')&.text, + policies: policies, + upload_types: upload_types + } + end + it 'updates the :types' do + described_class.send(:parse_repository, repo: @repo, node: @node) + expect(@repo.info['types']).to eql(@expected[:types]) + end + it 'updates the :subjects' do + described_class.send(:parse_repository, repo: @repo, node: @node) + expect(@repo.info['subjects']).to eql(@expected[:subjects]) + end + it 'updates the :provider_types' do + described_class.send(:parse_repository, repo: @repo, node: @node) + expect(@repo.info['provider_types']).to eql(@expected[:provider_types]) + end + it 'updates the :keywords' do + described_class.send(:parse_repository, repo: @repo, node: @node) + expect(@repo.info['keywords']).to eql(@expected[:keywords]) + end + it 'updates the :access' do + described_class.send(:parse_repository, repo: @repo, node: @node) + expect(@repo.info['access']).to eql(@expected[:access]) + end + it 'updates the :pid_system' do + described_class.send(:parse_repository, repo: @repo, node: @node) + expect(@repo.info['pid_system']).to eql(@expected[:pid_system]) + end + it 'updates the :policies' do + described_class.send(:parse_repository, repo: @repo, node: @node) + expect(@repo.info['policies'].to_json).to eql(@expected[:policies].to_json) + end + it 'updates the :upload_types' do + described_class.send(:parse_repository, repo: @repo, node: @node) + expect(@repo.info['upload_types'].to_json).to eql(@expected[:upload_types].to_json) + end + end + end + + describe '#parse_policy(node:)' do + before(:each) do + @node = Nokogiri::XML(@repository_result, nil, 'utf8') + base = @node.xpath('//r3d:re3data//r3d:repository').first + @expected = { + name: base.xpath('r3d:policyName')&.text, + url: base.xpath('r3d:policyURL')&.text + } + end + it 'returns nil if :node is not present' do + expect(described_class.send(:parse_policy, node: nil)).to eql(nil) + end + it 'updates the :name' do + expect(described_class.send(:parse_policy, node: @node)[:name]).to eql(@expected[:name]) + end + it 'updates the :url' do + expect(described_class.send(:parse_policy, node: @node)[:url]).to eql(@expected[:url]) + end + end + + describe '#parse_upload(node:)' do + before(:each) do + @node = Nokogiri::XML(@repository_result, nil, 'utf8') + base = @node.xpath('//r3d:re3data//r3d:repository').first + @expected = { + type: base.xpath('r3d:dataUploadType')&.text, + restriction: base.xpath('r3d:dataUploadRestriction')&.text + } + end + it 'returns nil if :node is not present' do + expect(described_class.send(:parse_upload, node: nil)).to eql(nil) + end + it 'updates the :type' do + expect(described_class.send(:parse_upload, node: @node)[:type]).to eql(@expected[:type]) + end + it 'updates the :restriction' do + result = described_class.send(:parse_upload, node: @node)[:restriction] + expect(result).to eql(@expected[:restriction]) + end + end + end +end diff --git a/spec/services/external_apis/spdx_service_spec.rb b/spec/services/external_apis/spdx_service_spec.rb new file mode 100644 index 0000000000..7cb3fa4e5b --- /dev/null +++ b/spec/services/external_apis/spdx_service_spec.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ExternalApis::SpdxService do + include Webmocks + + before(:each) do + License.all.destroy_all + + @licenses_results = { + reference: "./#{Faker::Lorem.unique.word}.html", + isDeprecatedLicenseId: [true, false].sample, + detailsUrl: Faker::Internet.unique.url, + referenceNumber: Faker::Number.unique.number(digits: 2), + name: Faker::Music::PearlJam.unique.album, + licenseId: Faker::Music::PearlJam.unique.song.upcase.gsub(/\s/, '_'), + seeAlso: [ + Faker::Internet.unique.url + ], + isOsiApproved: [true, false].sample + } + + stub_spdx_service(successful: true, response_body: { licenses: @licenses_results }.to_json) + end + + describe ':fetch' do + it 'returns an empty array if spdx did not return a repository list' do + described_class.expects(:query_spdx).returns(nil) + expect(described_class.fetch).to eql([]) + end + it 'fetches the licenses' do + described_class.expects(:query_spdx).returns({ licenses: @licenses_results }) + described_class.expects(:process_license).returns(true) + described_class.fetch + end + end + + context 'private methods' do + describe ':query_spdx' do + it 'calls the error handler if an HTTP 200 is not received from the SPDX API' do + stub_spdx_service(successful: false) + described_class.expects(:handle_http_failure) + expect(described_class.send(:query_spdx)).to eql([]) + end + it 'logs an error if the response was invalid JSON' do + JSON.expects(:parse).raises(JSON::ParserError.new) + described_class.expects(:log_error) + expect(described_class.send(:query_spdx)).to eql([]) + end + it 'returns an empty array if the response conatins no license' do + JSON.expects(:parse).returns({}) + expect(described_class.send(:query_spdx)).to eql([]) + end + it 'reuturns the array of licenses' do + expect(described_class.send(:query_spdx)).to eql(JSON.parse(@licenses_results.to_json)) + end + end + + describe ':process_license(hash:)' do + it 'returns nil if hash is empty' do + expect(described_class.send(:process_license, hash: nil)).to eql(nil) + end + + it 'returns nil if a License could not be initialized' do + License.expects(:find_or_initialize_by).returns(nil) + expect(described_class.send(:process_license, hash: @licenses_results)).to eql(nil) + end + + it 'updates existing License records' do + hash = @licenses_results + license = create(:license, identifier: hash[:licenseId]) + + expect(described_class.send(:process_license, hash: JSON.parse(hash.to_json))) + result = License.last + expect(result.id).to eql(license.id) + expect(result.name).to eql(hash[:name]) + expect(result.uri).to eql(hash[:detailsUrl]) + expect(result.osi_approved).to eql(hash[:isOsiApproved]) + expect(result.deprecated).to eql(hash[:isDeprecatedLicenseId]) + end + + it 'creates new License records' do + hash = @licenses_results + + expect(described_class.send(:process_license, hash: JSON.parse(hash.to_json))) + result = License.last + expect(result.identifier).to eql(hash[:licenseId]) + expect(result.name).to eql(hash[:name]) + expect(result.uri).to eql(hash[:detailsUrl]) + expect(result.osi_approved).to eql(hash[:isOsiApproved]) + expect(result.deprecated).to eql(hash[:isDeprecatedLicenseId]) + end + end + end +end diff --git a/spec/support/helpers/webmocks.rb b/spec/support/helpers/webmocks.rb index 6ff59d0d0b..a850e5c6c9 100644 --- a/spec/support/helpers/webmocks.rb +++ b/spec/support/helpers/webmocks.rb @@ -16,6 +16,16 @@ def stub_ror_service .to_return(status: 200, body: mocked_ror_response, headers: {}) end + def stub_spdx_service(successful: true, response_body: '') + stub_request(:get, %r{https://raw.githubusercontent.com/spdx/.*}) + .to_return(status: successful ? 200 : 500, body: response_body, headers: {}) + end + + def stub_rdamsc_service(successful: true, response_body: '') + stub_request(:get, %r{https://rdamsc.bath.ac.uk/.*}) + .to_return(status: successful ? 200 : 500, body: response_body, headers: {}) + end + def stub_openaire url = ExternalApis::OpenAireService.api_base_url url = "#{url}#{ExternalApis::OpenAireService.search_path}" diff --git a/spec/views/api/v1/datasets/_show.json.jbuilder_spec.rb b/spec/views/api/v1/datasets/_show.json.jbuilder_spec.rb index 2d4c4e76cc..fed21bbeb0 100644 --- a/spec/views/api/v1/datasets/_show.json.jbuilder_spec.rb +++ b/spec/views/api/v1/datasets/_show.json.jbuilder_spec.rb @@ -3,27 +3,125 @@ require 'rails_helper' describe 'api/v1/datasets/_show.json.jbuilder' do - before(:each) do - # TODO: Implement this once the Dataset models are in place - @plan = create(:plan) - render partial: 'api/v1/datasets/show', locals: { plan: @plan } - @json = JSON.parse(rendered).with_indifferent_access + context 'config has disabled madmp options' do + before(:each) do + @plan = create(:plan) + @output = create(:research_output, plan: @plan) + end + + it 'does not include :preservation_statement if config is false' do + Rails.configuration.x.madmp.extract_preservation_statements_from_themed_questions = false + render partial: 'api/v1/datasets/show', locals: { output: @output } + json = JSON.parse(rendered).with_indifferent_access + expect(json[:preservation_statement]).to eql('') + end + it 'does not include :security_and_privacy if config is false' do + Rails.configuration.x.madmp.extract_security_privacy_statements_from_themed_questions = false + render partial: 'api/v1/datasets/show', locals: { output: @output } + json = JSON.parse(rendered).with_indifferent_access + expect(json[:security_and_privacy]).to eql([]) + end + it 'does not include :data_quality_assurance if config is false' do + Rails.configuration.x.madmp.extract_data_quality_statements_from_themed_questions = false + render partial: 'api/v1/datasets/show', locals: { output: @output } + json = JSON.parse(rendered).with_indifferent_access + expect(json[:data_quality_assurance]).to eql('') + end end - describe 'includes all of the dataset attributes' do - it 'includes :title' do - expect(@json[:title]).to eql('Generic Dataset') + context 'config has enabled madmp options' do + before(:each) do + Rails.configuration.x.madmp.extract_preservation_statements_from_themed_questions = true + Rails.configuration.x.madmp.extract_security_privacy_statements_from_themed_questions = true + Rails.configuration.x.madmp.extract_data_quality_statements_from_themed_questions = true + + @plan = create(:plan) + @output = create(:research_output, plan: @plan) + render partial: 'api/v1/datasets/show', locals: { output: @output } + @json = JSON.parse(rendered).with_indifferent_access end - it 'includes :personal_data' do - expect(@json[:personal_data]).to eql('unknown') + + describe 'includes all of the dataset attributes' do + it 'includes :type' do + expect(@json[:type]).to eql(@output.output_type) + end + it 'includes :title' do + expect(@json[:title]).to eql(@output.title) + end + it 'includes :description' do + expect(@json[:description]).to eql(@output.description) + end + it 'includes :personal_data' do + expected = Api::V1::ApiPresenter.boolean_to_yes_no_unknown(value: @output.personal_data) + expect(@json[:personal_data]).to eql(expected) + end + it 'includes :sensitive_data' do + expected = Api::V1::ApiPresenter.boolean_to_yes_no_unknown(value: @output.sensitive_data) + expect(@json[:sensitive_data]).to eql(expected) + end + it 'includes :issued' do + expect(@json[:issued]).to eql(@output.release_date&.to_formatted_s(:iso8601)) + end + it 'includes :dataset_id' do + expect(@json[:dataset_id][:type]).to eql('other') + expect(@json[:dataset_id][:identifier]).to eql(@output.id.to_s) + end + context ':distribution info' do + before(:each) do + @distribution = @json[:distribution].first + end + it 'includes :byte_size' do + expect(@distribution[:byte_size]).to eql(@output.byte_size) + end + it 'includes :data_access' do + expect(@distribution[:data_access]).to eql(@output.access) + end + it 'includes :format' do + expect(@distribution[:format]).to eql(nil) + end + end + it 'includes :metadata' do + expect(@json[:metadata]).not_to eql([]) + expect(@json[:metadata].first[:description].present?).to eql(true) + expect(@json[:metadata].first[:metadata_standard_id].present?).to eql(true) + expect(@json[:metadata].first[:metadata_standard_id][:type].present?).to eql(true) + expect(@json[:metadata].first[:metadata_standard_id][:identifier].present?).to eql(true) + end + it 'includes :technical_resources' do + expect(@json[:technical_resources]).to eql(nil) + end end - it 'includes :sensitive_data' do - expect(@json[:sensitive_data]).to eql('unknown') + + describe 'includes all of the repository info as attributes' do + before(:each) do + @host = @json[:distribution].first[:host] + @expected = @output.repositories.last + end + it 'includes :title' do + expect(@host[:title]).to eql(@expected.name) + end + it 'includes :description' do + expect(@host[:description]).to eql(@expected.description) + end + it 'includes :url' do + expect(@host[:url]).to eql(@expected.homepage) + end + it 'includes :dmproadmap_host_id' do + expect(@host[:dmproadmap_host_id][:type]).to eql('url') + expect(@host[:dmproadmap_host_id][:identifier]).to eql(@expected.uri) + end end - it 'includes :dataset_id' do - expect(@json[:dataset_id][:type]).to eql('url') - url = Rails.application.routes.url_helpers.api_v1_plan_url(@plan) - expect(@json[:dataset_id][:identifier]).to eql(url) + + describe 'includes all of the themed question/answers as attributes' do + it 'includes :preservation_statement' do + expect(@json[:preservation_statement]).to eql('') + end + it 'includes :security_and_privacy' do + expect(@json[:security_and_privacy]).to eql([]) + end + it 'includes :data_quality_assurance' do + expect(@json[:data_quality_assurance]).to eql('') + end end end end diff --git a/spec/views/layouts/modal_search/_form.html.erb_spec.rb b/spec/views/layouts/modal_search/_form.html.erb_spec.rb new file mode 100644 index 0000000000..76b7ac25b2 --- /dev/null +++ b/spec/views/layouts/modal_search/_form.html.erb_spec.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'layouts/modal_search/_form.html.erb' do + before(:each) do + @model = create(:plan) + end + + it 'defaults to :search_examples to an empty string and :results to an empty array' do + render partial: 'layouts/modal_search/form', + locals: { + namespace: nil, + label: nil, + search_examples: nil, + model_instance: @model, + search_path: nil, + search_method: nil + } + expect(rendered.include?('- Enter a search term -')).to eql(true) + expect(rendered.include?('No results matched your filter criteria.')).to eql(true) + end + + it 'uses the specified :search_examples' do + examples = Faker::Lorem.sentence + render partial: 'layouts/modal_search/form', + locals: { + namespace: nil, + label: nil, + search_examples: examples, + model_instance: @model, + search_path: nil, + search_method: nil + } + expect(rendered.include?(examples)).to eql(true) + end + + it 'uses the :namespace when defining the modal search sections' do + namespace = Faker::Lorem.word.downcase + render partial: 'layouts/modal_search/form', + locals: { + namespace: namespace, + label: nil, + search_examples: nil, + model_instance: @model, + search_path: nil, + search_method: nil + } + expect(rendered.include?("modal-search-#{namespace}")).to eql(true) + expect(rendered.include?("modal-search-#{namespace}-filters")).to eql(true) + expect(rendered.include?("modal-search-#{namespace}-results")).to eql(true) + end + + it 'Uses the :label for the button' do + label = Faker::Lorem.word + render partial: 'layouts/modal_search/form', + locals: { + namespace: nil, + label: label, + search_examples: nil, + model_instance: @model, + search_path: nil, + search_method: nil + } + expect(rendered.include?("#{label} search")).to eql(true) + end + + it 'Uses the :model_instance when adding the form element' do + render partial: 'layouts/modal_search/form', + locals: { + namespace: nil, + label: nil, + search_examples: nil, + model_instance: @model, + search_path: nil, + search_method: nil + } + expect(rendered.include?(plan_path(@model))).to eql(true) + end + + it 'Uses the :search_path when adding the form element' do + url = Faker::Internet.url + render partial: 'layouts/modal_search/form', + locals: { + namespace: nil, + label: nil, + search_examples: nil, + model_instance: @model, + search_path: url, + search_method: nil + } + expect(rendered.include?(url)).to eql(true) + end + + it 'Uses the :search_method when adding the form element' do + method = %i[get put post patch delete].sample + render partial: 'layouts/modal_search/form', + locals: { + namespace: nil, + label: nil, + search_examples: nil, + model_instance: @model, + search_path: nil, + search_method: method + } + expect(rendered.include?(method.to_s)).to eql(true) + end +end diff --git a/spec/views/layouts/modal_search/_result.html.erb_spec.rb b/spec/views/layouts/modal_search/_result.html.erb_spec.rb new file mode 100644 index 0000000000..6266872334 --- /dev/null +++ b/spec/views/layouts/modal_search/_result.html.erb_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'layouts/modal_search/_result.html.erb' do + before(:each) do + @result = build(:repository) + end + + it 'renders the :result_partial if specified' do + render partial: 'layouts/modal_search/result', + locals: { + item_name_attr: :name, + result: @result, + selected: nil, + result_partial: 'layouts/footer', + search_path: nil, + search_method: nil + } + expect(response).to render_template(partial: 'layouts/_footer') + end + + it 'does not render the :result_partial if none is specified' do + render partial: 'layouts/modal_search/result', + locals: { + item_name_attr: :name, + result: @result, + selected: nil, + result_partial: nil, + search_path: nil, + search_method: nil + } + expect(response).not_to render_template(partial: 'layouts/footer') + end + + it "displays the result's :item_name_attr" do + render partial: 'layouts/modal_search/result', + locals: { + item_name_attr: :name, + result: @result, + selected: true, + result_partial: nil, + search_path: nil, + search_method: nil + } + expect(rendered.include?('modal-search-result-selector hidden')).to eql(true) + expect(rendered.include?('modal-search-result-unselector hidden')).to eql(false) + end + + it "hides the 'Select' button and shows the 'Remove' button when :selected is true" do + render partial: 'layouts/modal_search/result', + locals: { + item_name_attr: :name, + result: @result, + selected: true, + result_partial: nil, + search_path: nil, + search_method: nil + } + expect(rendered.include?('modal-search-result-selector hidden')).to eql(true) + expect(rendered.include?('modal-search-result-unselector hidden')).to eql(false) + end + + it "shows the 'Select' button and hides the 'Remove' button when :selected is false" do + render partial: 'layouts/modal_search/result', + locals: { + item_name_attr: :name, + result: @result, + selected: false, + result_partial: nil, + search_path: nil, + search_method: nil + } + expect(rendered.include?('modal-search-result-selector hidden')).to eql(false) + expect(rendered.include?('modal-search-result-unselector hidden')).to eql(true) + end +end diff --git a/spec/views/layouts/modal_search/_results.html.erb_spec.rb b/spec/views/layouts/modal_search/_results.html.erb_spec.rb new file mode 100644 index 0000000000..13fbea9d50 --- /dev/null +++ b/spec/views/layouts/modal_search/_results.html.erb_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'layouts/modal_search/_selections.html.erb' do + before(:each) do + create(:repository) + @msg = 'No results matched your filter criteria.' + end + + it 'defaults :results to an empty array, :selected to false, and has a default :no_results_msg' do + render partial: 'layouts/modal_search/results', + locals: { + namespace: nil, + item_name_attr: nil, + result_partial: nil, + search_path: nil, + search_method: nil + } + expect(rendered.include?(@msg)).to eql(true) + end + + context 'when :selected is false' do + it 'displays pagination when :results is not empty and does not display no results message' do + render partial: 'layouts/modal_search/results', + locals: { + namespace: nil, + results: Repository.all.page(1), + selected: false, + item_name_attr: nil, + result_partial: nil, + search_path: nil, + search_method: nil + } + expect(rendered.include?('modal-search-results-pagination')).to eql(true) + expect(rendered.include?(@msg)).to eql(false) + end + it 'does not display pagination when :results is empty and displays the message' do + render partial: 'layouts/modal_search/results', + locals: { + namespace: nil, + results: [], + selected: false, + item_name_attr: nil, + result_partial: nil, + search_path: nil, + search_method: nil + } + expect(rendered.include?('modal-search-results-pagination')).to eql(false) + expect(rendered.include?(@msg)).to eql(true) + end + end + + context 'when :selected is true' do + it 'does not display pagination when :results is not empty and does not display message' do + render partial: 'layouts/modal_search/results', + locals: { + namespace: nil, + results: Repository.all.page(1), + selected: true, + item_name_attr: nil, + result_partial: nil, + search_path: nil, + search_method: nil + } + expect(rendered.include?('modal-search-results-pagination')).to eql(false) + expect(rendered.include?(@msg)).to eql(false) + end + it 'does not display pagination when :results is empty and does not display message' do + render partial: 'layouts/modal_search/results', + locals: { + namespace: nil, + results: [], + selected: true, + item_name_attr: nil, + result_partial: nil, + search_path: nil, + search_method: nil + } + expect(rendered.include?('modal-search-results-pagination')).to eql(false) + expect(rendered.include?(@msg)).to eql(false) + end + end +end diff --git a/spec/views/layouts/modal_search/_selections.html.erb_spec.rb b/spec/views/layouts/modal_search/_selections.html.erb_spec.rb new file mode 100644 index 0000000000..46ea4ba805 --- /dev/null +++ b/spec/views/layouts/modal_search/_selections.html.erb_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'layouts/modal_search/_selections.html.erb' do + before(:each) do + @namespace = Faker::Lorem.word.downcase + @label = Faker::Lorem.sentence + render partial: 'layouts/modal_search/selections', + locals: { + namespace: @namespace, + button_label: @label, + results: [], + item_name_attr: Faker::Lorem.word, + result_partial: nil, + search_path: nil, + search_method: nil + } + end + + it 'adds the :namespace to the selections block' do + expect(rendered.include?("modal-search-#{@namespace}-selections")).to eql(true) + end + + it 'adds the :namespace to the button' do + expect(rendered.include?("target=\"#modal-search-#{@namespace}\"")).to eql(true) + end + + it 'sets the :button_label on the button' do + expect(rendered.include?(@label)).to eql(true) + end + + it 'adds the renders the results partial' do + expect(response).to render_template(partial: 'layouts/modal_search/_results') + end +end