Skip to content

Commit 3998e76

Browse files
Copilotfbacall
andcommitted
Add Author model and migration for authors has_many relationship
Co-authored-by: fbacall <503373+fbacall@users.noreply.github.com>
1 parent e8c653b commit 3998e76

16 files changed

Lines changed: 306 additions & 32 deletions

app/assets/javascripts/authors.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
var Authors = {
2+
add: function (firstName, lastName, orcid) {
3+
var newForm = $('#author-template').clone().html();
4+
5+
// Ensure the index of the new form is 1 greater than the current highest index, to prevent collisions
6+
var index = 0;
7+
$('#authors-list .author-form').each(function () {
8+
var newIndex = parseInt($(this).data('index'));
9+
if (newIndex > index) {
10+
index = newIndex;
11+
}
12+
});
13+
14+
// Replace the placeholder index with the actual index
15+
newForm = $(newForm.replace(/replace-me/g, index + 1));
16+
newForm.appendTo('#authors-list');
17+
18+
if (typeof firstName !== 'undefined' && typeof lastName !== 'undefined') {
19+
$('.author-first-name', newForm).val(firstName);
20+
$('.author-last-name', newForm).val(lastName);
21+
if (typeof orcid !== 'undefined') {
22+
$('.author-orcid', newForm).val(orcid);
23+
}
24+
}
25+
26+
return false; // Stop form being submitted
27+
},
28+
29+
// This is just cosmetic. The actual removal is done by rails,
30+
// by virtue of the hidden checkbox being checked when the label is clicked.
31+
delete: function () {
32+
$(this).parents('.author-form').fadeOut();
33+
}
34+
};
35+
36+
document.addEventListener("turbolinks:load", function() {
37+
38+
$('#authors')
39+
.on('click', '#add-author-btn', Authors.add)
40+
.on('change', '.delete-author-btn input.destroy-attribute', Authors.delete);
41+
});

app/controllers/materials_controller.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,11 +170,12 @@ def material_params
170170
:content_provider_id, :difficulty_level, :version, :status,
171171
:date_created, :date_modified, :date_published, :other_types,
172172
:prerequisites, :syllabus, :visible, :learning_objectives, { subsets: [] },
173-
{ contributors: [] }, { authors: [] }, { target_audience: [] },
173+
{ contributors: [] }, { target_audience: [] },
174174
{ collection_ids: [] }, { keywords: [] }, { resource_type: [] },
175175
{ scientific_topic_names: [] }, { scientific_topic_uris: [] },
176176
{ operation_names: [] }, { operation_uris: [] },
177177
{ node_ids: [] }, { node_names: [] }, { fields: [] },
178+
authors_attributes: %i[id first_name last_name orcid _destroy],
178179
external_resources_attributes: %i[id url title _destroy],
179180
external_resources: %i[url title],
180181
event_ids: [], locked_fields: [])

app/models/author.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
class Author < ApplicationRecord
2+
has_many :material_authors, dependent: :destroy
3+
has_many :materials, through: :material_authors
4+
5+
validates :first_name, :last_name, presence: true
6+
7+
def full_name
8+
"#{first_name} #{last_name}".strip
9+
end
10+
end

app/models/material.rb

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ class Material < ApplicationRecord
3030
text :description
3131
text :contact
3232
text :doi
33-
text :authors
33+
text :authors do
34+
authors.map(&:full_name)
35+
end
3436
text :contributors
3537
text :target_audience
3638
text :keywords
@@ -51,7 +53,9 @@ class Material < ApplicationRecord
5153
end
5254
# other fields
5355
string :title
54-
string :authors, multiple: true
56+
string :authors, multiple: true do
57+
authors.map(&:full_name)
58+
end
5559
string :scientific_topics, multiple: true do
5660
scientific_topics_and_synonyms
5761
end
@@ -102,6 +106,10 @@ class Material < ApplicationRecord
102106

103107
has_many :stars, as: :resource, dependent: :destroy
104108

109+
has_many :material_authors, dependent: :destroy
110+
has_many :authors, through: :material_authors
111+
accepts_nested_attributes_for :authors, allow_destroy: true, reject_if: :all_blank
112+
105113
# Remove trailing and squeezes (:squish option) white spaces inside the string (before_validation):
106114
# e.g. "James Bond " => "James Bond"
107115
auto_strip_attributes :title, :description, :url, squish: false
@@ -111,10 +119,10 @@ class Material < ApplicationRecord
111119
validates :other_types, presence: true, if: proc { |m| m.resource_type.include?('other') }
112120
validates :keywords, length: { maximum: 20 }
113121

114-
clean_array_fields(:keywords, :fields, :contributors, :authors,
122+
clean_array_fields(:keywords, :fields, :contributors,
115123
:target_audience, :resource_type, :subsets)
116124

117-
update_suggestions(:keywords, :contributors, :authors, :target_audience,
125+
update_suggestions(:keywords, :contributors, :target_audience,
118126
:resource_type)
119127

120128
def description=(desc)
@@ -212,7 +220,7 @@ def to_oai_dc
212220
'xsi:schemaLocation' => 'http://www.openarchives.org/OAI/2.0/oai_dc/ http://www.openarchives.org/OAI/2.0/oai_dc.xsd') do
213221
xml.tag!('dc:title', title)
214222
xml.tag!('dc:description', description)
215-
authors.each { |a| xml.tag!('dc:creator', a) }
223+
authors.each { |a| xml.tag!('dc:creator', a.full_name) }
216224
contributors.each { |a| xml.tag!('dc:contributor', a) }
217225
xml.tag!('dc:publisher', content_provider.title) if content_provider
218226

app/models/material_author.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
class MaterialAuthor < ApplicationRecord
2+
belongs_to :material
3+
belongs_to :author
4+
5+
validates :material, :author, presence: true
6+
end
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<% index ||= 'replace-me' %> <%# This is so we can render a blank version of this sub-form in the page, %>
2+
<%# which can be dynamically cloned using JavaScript to add more Authors to the main material form %>
3+
<% field_name_prefix = "#{form_name}[authors_attributes][#{index}]" %> <%# This format is dictated by "accepts_nested_attributes_for" %>
4+
5+
<div class="form-inline author-form" data-index="<%= index -%>">
6+
<%= hidden_field_tag("#{field_name_prefix}[id]", author.id) %>
7+
8+
<div class="form-group">
9+
<%= text_field_tag "#{field_name_prefix}[first_name]", author.first_name, :class => 'form-control author-first-name', :placeholder => 'First Name' %>
10+
</div>
11+
12+
<div class="form-group">
13+
<%= text_field_tag "#{field_name_prefix}[last_name]", author.last_name, :class => 'form-control author-last-name', :placeholder => 'Last Name' %>
14+
</div>
15+
16+
<div class="form-group">
17+
<%= text_field_tag "#{field_name_prefix}[orcid]", author.orcid, :class => 'form-control author-orcid', :placeholder => 'ORCID (optional)' %>
18+
</div>
19+
20+
<label class="ml-2 delete-author-btn">
21+
<i class="icon icon-sm cross-icon icon-greyscale"></i>
22+
<%= hidden_field_tag "#{field_name_prefix}[_destroy]", '0', :autocomplete => 'off' %>
23+
<%= check_box_tag "#{field_name_prefix}[_destroy]", '1', false, :class => 'destroy-attribute', :autocomplete => 'off' %>
24+
</label>
25+
</div>

app/views/materials/_form.html.erb

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,22 @@
6868
visibility_toggle: TeSS::Config.feature['materials_disabled'] %>
6969

7070
<!-- Field: Authors -->
71-
<%= f.multi_input :authors, suggestions_url: people_autocomplete_suggestions_path, title: t('materials.hints.authors'),
72-
visibility_toggle: TeSS::Config.feature['materials_disabled'] %>
71+
<div class="form-group" id="authors">
72+
<%= f.label :authors %>
73+
<%= f.field_lock :authors %>
74+
75+
<div id="authors-list">
76+
<% @material.authors.each_with_index do |author, index| %>
77+
<%= render partial: 'common/author_form',
78+
locals: { form_name: 'material', index: index, author: author } %>
79+
<% end %>
80+
</div>
81+
82+
<a href="#" id="add-author-btn" class="btn btn-icon">
83+
<i class="icon icon-h4 plus-icon"></i>
84+
</a>
85+
<span class="help-inline-block help-block">Add author information</span>
86+
</div>
7387

7488
<!-- Field: Contributors -->
7589
<%= f.multi_input :contributors, suggestions_url: people_autocomplete_suggestions_path, title: t('materials.hints.contributors'),
@@ -195,6 +209,10 @@
195209
locals: { form_name: 'material', external_resource: ExternalResource.new } %>
196210
</div>
197211

212+
<div id="author-template" style="display: none">
213+
<%= render partial: 'common/author_form',
214+
locals: { form_name: 'material', author: Author.new } %>
215+
</div>
198216

199217
<script type="text/javascript">
200218
function removeSuggestion(suggestion) {
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
class CreateAuthors < ActiveRecord::Migration[7.2]
2+
def change
3+
create_table :authors do |t|
4+
t.string :first_name
5+
t.string :last_name
6+
t.string :orcid
7+
8+
t.timestamps
9+
end
10+
11+
add_index :authors, :orcid
12+
end
13+
end
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
class CreateMaterialAuthors < ActiveRecord::Migration[7.2]
2+
def change
3+
create_table :material_authors do |t|
4+
t.references :material, null: false, foreign_key: true
5+
t.references :author, null: false, foreign_key: true
6+
7+
t.timestamps
8+
end
9+
10+
add_index :material_authors, [:material_id, :author_id], unique: true
11+
end
12+
end
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
class MigrateMaterialAuthorsData < ActiveRecord::Migration[7.2]
2+
def up
3+
# Migrate existing authors from array to Author model
4+
Material.find_each do |material|
5+
next if material.authors.blank?
6+
7+
material.authors.each do |author_name|
8+
next if author_name.blank?
9+
10+
# Parse the name - assume "First Last" format
11+
# Handle edge cases: single names, multiple spaces, etc.
12+
parts = author_name.strip.split(/\s+/, 2)
13+
14+
if parts.length == 1
15+
# Single name - use as last name with empty first name
16+
first_name = ''
17+
last_name = parts[0]
18+
else
19+
first_name = parts[0]
20+
last_name = parts[1] || ''
21+
end
22+
23+
# Find or create author
24+
author = Author.find_or_create_by!(
25+
first_name: first_name,
26+
last_name: last_name
27+
)
28+
29+
# Create the association if it doesn't exist
30+
MaterialAuthor.find_or_create_by!(
31+
material_id: material.id,
32+
author_id: author.id
33+
)
34+
end
35+
end
36+
end
37+
38+
def down
39+
# Restore authors array from Author model
40+
Material.find_each do |material|
41+
author_names = material.authors.map(&:full_name).compact
42+
# Use raw SQL to avoid validation issues
43+
Material.connection.execute(
44+
"UPDATE materials SET authors = ARRAY[#{author_names.map { |n| "'#{n.gsub("'", "''")}'" }.join(',')}]::varchar[] WHERE id = #{material.id}"
45+
)
46+
end
47+
48+
# Clean up the new tables
49+
MaterialAuthor.delete_all
50+
Author.delete_all
51+
end
52+
end

0 commit comments

Comments
 (0)