diff --git a/app/assets/javascripts/people.js b/app/assets/javascripts/people.js new file mode 100644 index 000000000..b364a80cd --- /dev/null +++ b/app/assets/javascripts/people.js @@ -0,0 +1,39 @@ +var People = { + add: function (role) { + var templateId = '#person-' + role + '-template'; + var listId = '#person-' + role + '-list'; + var newForm = $(templateId).clone().html(); + + // Ensure the index of the new form is 1 greater than the current highest index, to prevent collisions + var index = 0; + $(listId + ' .person-form').each(function () { + var newIndex = parseInt($(this).data('index')); + if (newIndex > index) { + index = newIndex; + } + }); + + // Replace the placeholder index with the actual index + newForm = $(newForm.replace(/replace-me/g, index + 1)); + newForm.appendTo(listId); + + return false; // Stop form being submitted + }, + + // This is just cosmetic. The actual removal is done by rails, + // by virtue of the hidden checkbox being checked when the label is clicked. + delete: function () { + $(this).parents('.person-form').fadeOut(); + } +}; + +document.addEventListener("turbolinks:load", function() { + + $('[id^="person-"]') + .on('click', '[id^="add-person-"]', function() { + var role = $(this).data('role'); + People.add(role); + return false; + }) + .on('change', '.delete-person-btn input.destroy-attribute', People.delete); +}); diff --git a/app/assets/stylesheets/external-resources.scss b/app/assets/stylesheets/external-resources.scss index 839776636..f14d3bbea 100644 --- a/app/assets/stylesheets/external-resources.scss +++ b/app/assets/stylesheets/external-resources.scss @@ -1,13 +1,3 @@ -.external-resource-form { - padding-bottom: 10px; - margin-bottom: 10px; - - &.deleted { - color: $brand-danger; - text-decoration: line-through; - } -} - .associate-resource { cursor: pointer; } diff --git a/app/assets/stylesheets/forms.scss b/app/assets/stylesheets/forms.scss index 42685528f..a1c0cc9db 100644 --- a/app/assets/stylesheets/forms.scss +++ b/app/assets/stylesheets/forms.scss @@ -36,7 +36,17 @@ label.required abbr { margin-top: 0; } -.delete-external-resource-btn { +.nested-resource-form { + padding-bottom: 10px; + margin-bottom: 10px; + + &.deleted { + color: $brand-danger; + text-decoration: line-through; + } +} + +.delete-nested-resource-btn { @include delete-cross; cursor: pointer; diff --git a/app/controllers/materials_controller.rb b/app/controllers/materials_controller.rb index 72806976d..6c07d0c08 100644 --- a/app/controllers/materials_controller.rb +++ b/app/controllers/materials_controller.rb @@ -171,11 +171,12 @@ def material_params :content_provider_id, :difficulty_level, :version, :status, :date_created, :date_modified, :date_published, :other_types, :prerequisites, :syllabus, :visible, :learning_objectives, { subsets: [] }, - { contributors: [] }, { authors: [] }, { target_audience: [] }, + { authors: [] }, { contributors: [] }, { target_audience: [] }, { collection_ids: [] }, { keywords: [] }, { resource_type: [] }, { scientific_topic_names: [] }, { scientific_topic_uris: [] }, { operation_names: [] }, { operation_uris: [] }, { node_ids: [] }, { node_names: [] }, { fields: [] }, + people_attributes: %i[id role _destroy full_name orcid], external_resources_attributes: %i[id url title _destroy], external_resources: %i[url title], event_ids: [], locked_fields: []) diff --git a/app/models/concerns/has_orcid.rb b/app/models/concerns/has_orcid.rb new file mode 100644 index 000000000..eabde600a --- /dev/null +++ b/app/models/concerns/has_orcid.rb @@ -0,0 +1,18 @@ +module HasOrcid + extend ActiveSupport::Concern + + included do + auto_strip_attributes :orcid + before_validation :normalize_orcid + end + + def orcid_url + return nil if orcid.blank? + "#{OrcidValidator::ORCID_PREFIX}#{orcid}" + end + + def normalize_orcid + return if orcid.blank? + self.orcid = orcid.strip.sub(OrcidValidator::ORCID_DOMAIN_REGEX, '') + end +end diff --git a/app/models/concerns/has_people.rb b/app/models/concerns/has_people.rb new file mode 100644 index 000000000..f30b0591e --- /dev/null +++ b/app/models/concerns/has_people.rb @@ -0,0 +1,42 @@ +module HasPeople + extend ActiveSupport::Concern + + included do + has_many :people, as: :resource, dependent: :destroy, inverse_of: :resource + accepts_nested_attributes_for :people, allow_destroy: true, reject_if: :all_blank + end + + class_methods do + # Define a person role association (e.g., :authors, :contributors) + # This creates the association and a custom setter that accepts strings, hashes, or Person objects + def has_person_role(role_name, role_key: role_name.to_s.singularize) + # Define the association + has_many role_name, -> { where(role: role_key) }, class_name: 'Person', as: :resource, inverse_of: :resource + + # Define custom setter that accepts strings (legacy), hashes, or Person objects + define_method("#{role_name}=") do |value| + super(set_people_for_role(value, role_key)) + end + end + end + + private + + # Set people for a specific role, accepting various input formats + def set_people_for_role(value, role_key) + # Remove existing links for this role + people.where(role: role_key).destroy_all + + Array(value).reject(&:blank?).map do |person_data| + if person_data.is_a?(String) + # Legacy format: store as full_name directly + people.build(full_name: person_data.strip, role: role_key) + elsif person_data.is_a?(Hash) + people.build(**person_data, role: role_key) + elsif person_data.is_a?(Person) + person_data.role = role_key + person_data + end + end + end +end diff --git a/app/models/material.rb b/app/models/material.rb index 40e7fc83e..74c2e4bfe 100644 --- a/app/models/material.rb +++ b/app/models/material.rb @@ -21,6 +21,7 @@ class Material < ApplicationRecord include HasDifficultyLevel include HasEdamTerms include InSpace + include HasPeople if TeSS::Config.solr_enabled # :nocov: @@ -30,8 +31,12 @@ class Material < ApplicationRecord text :description text :contact text :doi - text :authors - text :contributors + text :authors do + authors.map(&:display_name) + end + text :contributors do + contributors.map(&:display_name) + end text :target_audience text :keywords text :resource_type @@ -51,7 +56,9 @@ class Material < ApplicationRecord end # other fields string :title - string :authors, multiple: true + string :authors, multiple: true do + authors.map(&:display_name) + end string :scientific_topics, multiple: true do scientific_topics_and_synonyms end @@ -62,7 +69,9 @@ class Material < ApplicationRecord string :keywords, multiple: true string :fields, multiple: true string :resource_type, multiple: true - string :contributors, multiple: true + string :contributors, multiple: true do + contributors.map(&:display_name) + end string :content_provider do content_provider.try(:title) end @@ -102,6 +111,10 @@ class Material < ApplicationRecord has_many :stars, as: :resource, dependent: :destroy + # Use HasPeople concern for authors and contributors + has_person_role :authors, role_key: 'author' + has_person_role :contributors, role_key: 'contributor' + # Remove trailing and squeezes (:squish option) white spaces inside the string (before_validation): # e.g. "James Bond " => "James Bond" auto_strip_attributes :title, :description, :url, squish: false @@ -111,10 +124,10 @@ class Material < ApplicationRecord validates :other_types, presence: true, if: proc { |m| m.resource_type.include?('other') } validates :keywords, length: { maximum: 20 } - clean_array_fields(:keywords, :fields, :contributors, :authors, + clean_array_fields(:keywords, :fields, :target_audience, :resource_type, :subsets) - update_suggestions(:keywords, :contributors, :authors, :target_audience, + update_suggestions(:keywords, :target_audience, :resource_type) def description=(desc) @@ -212,8 +225,8 @@ def to_oai_dc 'xsi:schemaLocation' => 'http://www.openarchives.org/OAI/2.0/oai_dc/ http://www.openarchives.org/OAI/2.0/oai_dc.xsd') do xml.tag!('dc:title', title) xml.tag!('dc:description', description) - authors.each { |a| xml.tag!('dc:creator', a) } - contributors.each { |a| xml.tag!('dc:contributor', a) } + authors.each { |a| xml.tag!('dc:creator', a.display_name) } + contributors.each { |c| xml.tag!('dc:contributor', c.display_name) } xml.tag!('dc:publisher', content_provider.title) if content_provider xml.tag!('dc:format', 'text/html') diff --git a/app/models/person.rb b/app/models/person.rb new file mode 100644 index 000000000..5604fd4af --- /dev/null +++ b/app/models/person.rb @@ -0,0 +1,38 @@ +class Person < ApplicationRecord + include HasOrcid + + belongs_to :profile, optional: true + belongs_to :resource, polymorphic: true + + validates :resource, :role, :full_name, presence: true + + # Automatically link to profile based on ORCID on save + before_save :link_to_profile_by_orcid + + # Return the display name - currently just the full_name + def display_name + full_name + end + + # Extract person attributes from a string containing a person's name and possibly an ORCID. + def self.attr_from_string(person_string) + orcid = nil + name = person_string.gsub(/\s*\(?(orcid: )?(https?:\/\/orcid\.org\/)?(\d\d\d\d-\d\d\d\d-\d\d\d\d-\d\d\d[\dxX])[ \)]*/) do |_| + orcid = $3 + '' + end.strip + { full_name: name, orcid: orcid } + end + + private + + # Automatically link to a Profile if one exists with a matching ORCID + def link_to_profile_by_orcid + if orcid.blank? + self.profile = nil + else + matching_profile = Profile.find_by(orcid: orcid) + self.profile = matching_profile if matching_profile.present? + end + end +end diff --git a/app/models/profile.rb b/app/models/profile.rb index 9935bcca9..85ef53903 100644 --- a/app/models/profile.rb +++ b/app/models/profile.rb @@ -1,7 +1,9 @@ require 'uri' class Profile < ApplicationRecord - auto_strip_attributes :firstname, :surname, :website, :orcid, squish: false + include HasOrcid + + auto_strip_attributes :firstname, :surname, :website, squish: false belongs_to :user, inverse_of: :profile before_validation :normalize_orcid @@ -25,11 +27,6 @@ def full_name "#{firstname} #{surname}".strip end - def orcid_url - return nil if orcid.blank? - "#{OrcidValidator::ORCID_PREFIX}#{orcid}" - end - def merge(*others) Profile.transaction do attrs = attributes @@ -66,11 +63,6 @@ def authenticate_orcid(orcid) private - def normalize_orcid - return if orcid.blank? - self.orcid = orcid.strip.sub(OrcidValidator::ORCID_DOMAIN_REGEX, '') - end - def check_public public ? self.type = 'Trainer' : self.type = 'Profile' end diff --git a/app/serializers/application_serializer.rb b/app/serializers/application_serializer.rb index 2837c33ab..b7480c659 100644 --- a/app/serializers/application_serializer.rb +++ b/app/serializers/application_serializer.rb @@ -31,5 +31,9 @@ def ontology_terms(type) object.send(type).map { |t| { preferred_label: t.preferred_label, uri: t.uri } } end + def people(type) + object.send(type).map(&:display_name) + end + link(:self) { polymorphic_path(object) } end \ No newline at end of file diff --git a/app/serializers/material_serializer.rb b/app/serializers/material_serializer.rb index 4500fe153..548c37714 100644 --- a/app/serializers/material_serializer.rb +++ b/app/serializers/material_serializer.rb @@ -18,4 +18,12 @@ class MaterialSerializer < ApplicationSerializer has_many :nodes has_many :collections has_many :events + + def contributors + people(:contributors) + end + + def authors + people(:authors) + end end diff --git a/app/views/common/_external_resource_form.html.erb b/app/views/common/_external_resource_form.html.erb index 19d93a0c9..c276607cf 100644 --- a/app/views/common/_external_resource_form.html.erb +++ b/app/views/common/_external_resource_form.html.erb @@ -2,7 +2,7 @@ <%# which can be dynamically cloned using JavaScript to add more ExternalResources to the main material form %> <% field_name_prefix = "#{form_name}[external_resources_attributes][#{index}]" %> <%# This format is dictated by "accepts_nested_attributes_for" %> -