Skip to content
Open
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
294f526
Recognize orcid and badge_for in compound schema
ShanaLMoore Jun 8, 2026
855bcb7
Add orcid_badge helper for compound rendering
ShanaLMoore Jun 8, 2026
2566f88
Inline orcid badge next to sibling in show
ShanaLMoore Jun 8, 2026
641f40b
Add orcid form input to compound row
ShanaLMoore Jun 8, 2026
eb53303
Validate orcid sub-property format on save
ShanaLMoore Jun 8, 2026
17f5e45
Validate orcid declarations in m3 profile
ShanaLMoore Jun 8, 2026
fcc45ba
Add participant_orcid to sample participants compound
ShanaLMoore Jun 8, 2026
0178608
Add English orcid compound locale keys
ShanaLMoore Jun 8, 2026
d213ad9
Translate orcid compound keys to six locales
ShanaLMoore Jun 8, 2026
9ff128b
Document type: orcid sub-property and badge_for
ShanaLMoore Jun 8, 2026
2f2cd5f
Resolve orcid badge asset path in renderer context
ShanaLMoore Jun 9, 2026
d5c4104
Facet-link sub-property values via search_field
ShanaLMoore Jun 9, 2026
74e4e5e
Apply participants to Monograph and GenericWork
ShanaLMoore Jun 9, 2026
28ce594
Update compound form spec for orcid sub-property
ShanaLMoore Jun 9, 2026
b396796
Keep unparseable orcid as standalone row
ShanaLMoore Jun 9, 2026
8dc12f9
Clarify orcid form input comment
ShanaLMoore Jun 9, 2026
628a20d
Use GenericWorkResource in central m3 participants
ShanaLMoore Jun 9, 2026
5557740
Order orcid badge img properties alphabetically
ShanaLMoore Jun 9, 2026
a44ffe6
Quiet hound complaints in compound_metadata.js
ShanaLMoore Jun 9, 2026
3a586c2
Alphabetize properties in compound_metadata.scss
ShanaLMoore Jun 9, 2026
4d37dde
Use non-block link_to in orcid_badge helper
ShanaLMoore Jun 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .koppie/config/metadata_profiles/m3_profile.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -938,6 +938,7 @@ properties:
available_on:
class:
- GenericWork
- Monograph
- CollectionResource
data_type: array
type: hash
Expand Down Expand Up @@ -968,6 +969,7 @@ properties:
group: identity
required: true
indexing: [participant_name_sim, participant_name_tesim]
search_field: participant_name_sim
form:
cols: 4
participant_role:
Expand All @@ -979,6 +981,17 @@ properties:
indexing: [participant_role_sim, participant_role_tesim]
form:
cols: 4
# An ORCID iD sub-property. `badge_for:` binds the iD to a sibling so the
# show renderer appends an inline ORCID badge next to that sibling's value
# instead of rendering the iD as its own row.
participant_orcid:
type: orcid
subproperty_of: participants
group: identity
badge_for: participant_name
indexing: [participant_orcid_ssim]
form:
cols: 4
identifiers:
available_on:
class:
Expand Down
56 changes: 33 additions & 23 deletions app/assets/javascripts/hyrax/compound_metadata.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
// rebinding. The sentinel guards against the IIFE running twice (Turbolinks
// re-evaluates module scripts on some navigation paths), which would stack
// duplicate listeners. Mirrors hyrax/redirects.js.
(function() {
if (document.hyraxCompoundsBound) return;
(function () {
if (document.hyraxCompoundsBound) { return; }
document.hyraxCompoundsBound = true;

// Bind select2 to a `work_or_url` sub-property. The v3 API (Hyrax bundles
Expand Down Expand Up @@ -58,41 +58,51 @@
}
document.addEventListener('turbolinks:load', bindAll);

document.addEventListener('click', function(event) {
// Remove a compound row: persisted rows are hidden + their `_destroy` flag
// flipped so the server-side populator drops them; unsaved rows are
// removed from the DOM outright. Returns true when the click was a
// remove-row gesture (so the dispatcher can stop further handling).
function handleRemoveClick(event) {
var removeButton = event.target.closest('[data-hyrax-compound-remove-row]');
if (removeButton) {
var row = removeButton.closest('[data-hyrax-compound-row]');
if (!row) return;
var destroyFlag = row.querySelector('[data-hyrax-compound-destroy-flag]');
if (destroyFlag && destroyFlag.value !== undefined) {
// Persisted rows: flip the _destroy flag and hide so the
// populator drops the row server-side.
destroyFlag.value = '1';
row.style.display = 'none';
} else {
row.parentNode.removeChild(row);
}
return;
if (!removeButton) { return false; }
var row = removeButton.closest('[data-hyrax-compound-row]');
if (!row) { return true; }
var destroyFlag = row.querySelector('[data-hyrax-compound-destroy-flag]');
if (destroyFlag && destroyFlag.value !== undefined) {
destroyFlag.value = '1';
row.style.display = 'none';
} else {
row.parentNode.removeChild(row);
}
return true;
}

// Add a compound row: clone the section's template, swap the `__INDEX__`
// placeholder for a monotonic counter, append, and bind any select2
// inputs the new row carries. Returns true when the click was an
// add-row gesture.
function handleAddClick(event) {
Comment thread
ShanaLMoore marked this conversation as resolved.
var addButton = event.target.closest('[data-hyrax-compound-add-row]');
if (!addButton) return;
if (!addButton) { return false; }
var section = addButton.closest('[data-hyrax-compound]');
if (!section) return;
if (!section) { return true; }
var template = section.querySelector('[data-hyrax-compound-row-template]');
var rowsHost = section.querySelector('[data-hyrax-compound-rows]');
if (!template || !rowsHost) return;
if (!template || !rowsHost) { return true; }

// Monotonic counter on the section; never recycle an index after a
// row is removed. Fallback to row count when the attribute is missing.
var nextIndex = parseInt(section.dataset.nextIndex, 10);
if (isNaN(nextIndex)) {
nextIndex = rowsHost.querySelectorAll('[data-hyrax-compound-row]').length;
}
var html = template.innerHTML.replace(/__INDEX__/g, nextIndex);
rowsHost.insertAdjacentHTML('beforeend', html);
// Bind select2 on any work_or_url inputs in the row just added.
bindWorkOrUrlInputs(rowsHost.lastElementChild || rowsHost);
section.dataset.nextIndex = String(nextIndex + 1);
return true;
}

document.addEventListener('click', function (event) {
if (handleRemoveClick(event)) { return; }
handleAddClick(event);
});
})();
}());
24 changes: 19 additions & 5 deletions app/assets/stylesheets/hyrax/_compound_metadata.scss
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@
// in _collections.scss) that would otherwise turn each entry into a bordered
// flex row. These rules override it regardless of source order.
.hyrax-compound-values {
border: 0 !important;
Comment thread
ShanaLMoore marked this conversation as resolved.
display: block !important;
Comment thread
ShanaLMoore marked this conversation as resolved.
flex-direction: column;
border: 0 !important;
padding: 0 !important;
max-width: 100%;
padding: 0 !important;
Comment thread
ShanaLMoore marked this conversation as resolved.

// Force block flow on the entries/sub-properties; the label/value spans stay
// inline so each sub-property reads as "Label: value" on one wrapping line.
Expand All @@ -23,17 +23,17 @@
.hyrax-compound-subproperty,
.hyrax-compound-subproperty-label,
.hyrax-compound-subproperty-value {
background: none;
border: 0 !important;
Comment thread
ShanaLMoore marked this conversation as resolved.
padding: 0 !important;
margin: 0;
background: none;
padding: 0 !important;
Comment thread
ShanaLMoore marked this conversation as resolved.
}

// A light divider between entries.
.hyrax-compound-entry + .hyrax-compound-entry {
border-top: 1px solid $gray-200 !important;
Comment thread
ShanaLMoore marked this conversation as resolved.
margin-top: 0.5rem;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Properties should be ordered border-top, margin-top, padding-top

padding-top: 0.5rem !important;
Comment thread
ShanaLMoore marked this conversation as resolved.
border-top: 1px solid $gray-200 !important;
}

.hyrax-compound-subproperty {
Expand All @@ -48,3 +48,17 @@
margin-right: 0.25rem;
}
}

// Small inline ORCID iD badge appended next to a sibling sub-property value
// when a `type: orcid` sub-property declares `badge_for:`.
.hyrax-compound-orcid-badge {
display: inline-block;
margin-left: 0.25rem;
vertical-align: baseline;

img {
height: 1em;
vertical-align: -0.125em;
width: auto;
}
}
1 change: 1 addition & 0 deletions app/forms/hyrax/forms/resource_form.rb
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ class ResourceForm < Hyrax::ChangeSet # rubocop:disable Metrics/ClassLength
# `attributes:`), so it is replay-safe.
validation(name: :default, inherit: true) do
validates_with Hyrax::CompoundEntryValidator
validates_with Hyrax::OrcidSubpropertyValidator
end

##
Expand Down
35 changes: 35 additions & 0 deletions app/helpers/hyrax/compound_fields_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,41 @@ def compound_subproperty_label(compound_name, sub_property)
default: sub_property.to_s.humanize)
end

##
# Renders a small linked ORCID iD badge. The href targets
# `https://orcid.org/<id>`; the bare iD is extracted from either input form
# (bare or full URL) via {Hyrax::OrcidValidator.extract_bare_orcid}, so the
# caller does not need to normalize first.
#
# @param value [String, nil] a bare ORCID iD (`0000-0000-0000-0000`) or the
# full URL form
# @param name [String, nil] the sibling's display value; used in the
# accessibility / hover label ("ORCID iD for Hosseini, Mohammad").
# When blank, falls back to the plain "ORCID iD" alt text.
# @return [ActiveSupport::SafeBuffer, nil] the badge HTML, or nil when
# +value+ is blank or unparseable.
def orcid_badge(value, name: nil)
bare_id = Hyrax::OrcidValidator.extract_bare_orcid(from: value.to_s)
return nil if bare_id.blank?

alt = t('hyrax.compound_fields.orcid.badge_alt', default: 'ORCID iD')
label = name.present? ? t('hyrax.compound_fields.orcid.badge_aria', name: name, default: "ORCID iD for #{name}") : alt
# `ActionController::Base.helpers.image_tag` (not the bare `image_tag`)
# resolves the asset path through a full view-equivalent context, so the
# badge image works the same whether the helper is called from a view
# (helper context) or from inside `CompoundAttributeRenderer` (a non-view
# Ruby object that only includes the helper module).
img = ActionController::Base.helpers.image_tag('orcid.png', alt: alt)
link_to "https://orcid.org/#{bare_id}",
class: 'hyrax-compound-orcid-badge',
target: '_blank',
rel: 'noopener noreferrer',
'aria-label' => label,
title: label do
img
end
end

private

# Options from a QA local authority. Uses {Hyrax::TolerantSelectService} so
Expand Down
75 changes: 67 additions & 8 deletions app/renderers/hyrax/renderers/compound_attribute_renderer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ module Renderers
# sub-properties produced by the SolrDocument `compound_attribute` reader —
# and renders as a block of its populated sub-properties. Sub-property labels
# come from the `hyrax.compound_fields.<compound>.<subproperty>` i18n keys.
class CompoundAttributeRenderer < AttributeRenderer
class CompoundAttributeRenderer < AttributeRenderer # rubocop:disable Metrics/ClassLength
include ActionView::Helpers::AssetTagHelper
include Hyrax::CompoundFieldsHelper

def render
return '' if blank_values? && !options[:include_empty]

Expand Down Expand Up @@ -51,21 +54,35 @@ def entry_markup(entry)
pairs = entry_to_pairs(entry)
return '' if pairs.empty?

items = pairs.map { |sub_property, value| subproperty_markup(sub_property, value) }.join
bindings = badge_attachments(pairs)
visible = pairs.reject { |(sub_property, _)| bindings[:skip].include?(sub_property) }
return '' if visible.empty?

items = visible.map do |sub_property, value|
subproperty_markup(sub_property, value, attached_orcid: bindings[:attach][sub_property])
end.join
%(<div class="hyrax-compound-entry">#{items}</div>)
end

def subproperty_markup(sub_property, value)
def subproperty_markup(sub_property, value, attached_orcid: nil)
label_html = ERB::Util.h(sub_property_label(sub_property))
value_html = value_markup(sub_property, value).to_s
if attached_orcid.present?
badge = orcid_badge(attached_orcid, name: display_value(sub_property, value).to_s).to_s
value_html = "#{value_html} #{badge}" unless badge.empty?
end
%(<div class="hyrax-compound-subproperty">) +
%(<span class="hyrax-compound-subproperty-label">#{label_html}:</span> ) +
%(<span class="hyrax-compound-subproperty-value">#{value_markup(sub_property, value)}</span>) +
%(<span class="hyrax-compound-subproperty-value">#{value_html}</span>) +
%(</div>)
end

# Display markup for one sub-property value, by sub-property type: `url` and
# `work_or_url` are linked; otherwise escaped text with controlled ids
# translated to their term.
# Display markup for one sub-property value, by sub-property type: `url`
# and `work_or_url` are linked; `orcid` renders as a standalone badge
# (used when no `badge_for:` sibling is bound); otherwise escaped text
# with controlled ids translated to their term. A string sub-property
# that declares `search_field:` is wrapped in a facet search link to
# that Solr field, matching the scalar `render_as: faceted` behavior.
def value_markup(sub_property, value)
return ERB::Util.h(display_value(sub_property, value)) if value.blank?

Expand All @@ -76,9 +93,51 @@ def value_markup(sub_property, value)
work_or_url_markup(value)
when 'controlled'
controlled_markup(sub_property, value)
when 'orcid'
# When the value is unparseable, `orcid_badge` returns nil — fall
# back to escaped text so the stored value is still visible.
orcid_badge(value).presence || ERB::Util.h(value.to_s)
else
ERB::Util.h(display_value(sub_property, value))
string_markup(sub_property, value)
end
end

# Plain string: facet-link the value when the sub-property opts in via
# `search_field:`, otherwise escape and emit verbatim.
def string_markup(sub_property, value)
spec = subproperty_spec(sub_property)
facet = spec.is_a?(Hash) ? spec[:search_field].to_s : ''
display = display_value(sub_property, value)
return ERB::Util.h(display) if facet.blank?

link_to(ERB::Util.h(display),
Rails.application.routes.url_helpers
.search_catalog_path("f[#{ERB::Util.h(facet)}][]": value, locale: I18n.locale))
end

# For each row, decide which `type: orcid` sub-properties attach inline
# to a sibling rather than render as their own row. Returns
# `{ skip: [<source>...], attach: { <target> => <orcid_value> } }`.
# An orcid is skipped (and the badge attached) only when its value
# parses to a bare iD via {Hyrax::OrcidValidator.extract_bare_orcid} —
# an unparseable value falls back to its own row so the stored value
# is still surfaced on the show page rather than silently dropped.
def badge_attachments(pairs)
return { skip: [], attach: {} } unless options[:subproperties].is_a?(Hash)

by_key = pairs.to_h
skip = []
attach = {}
pairs.each do |sub_property, value|
spec = subproperty_spec(sub_property)
next unless spec.is_a?(Hash) && spec[:type].to_s == 'orcid'
target = spec[:badge_for].to_s
next if target.blank? || by_key[target].blank?
next if Hyrax::OrcidValidator.extract_bare_orcid(from: value.to_s).blank?
skip << sub_property
attach[target] = value
end
Comment thread
ShanaLMoore marked this conversation as resolved.
{ skip: skip, attach: attach }
end

# A controlled value displays its authority/value-list term. When the
Expand Down
4 changes: 3 additions & 1 deletion app/services/hyrax/compound_schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,9 @@ def normalize_subproperty(opts)
required: truthy?(opts['required']),
group: opts['group']&.to_s,
cols: (form['cols'] || DEFAULT_COLS).to_i,
as: form['as']&.to_s }
as: form['as']&.to_s,
badge_for: opts['badge_for']&.to_s,
search_field: opts['search_field']&.to_s }
end

# Whether the compound itself is required (at least one row must exist).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,50 @@ def validate_subproperty(name, config)
return
end

return unless config['type'].to_s == 'controlled'
return if config['authority'].present? || config['values'].present?
case config['type'].to_s
when 'controlled' then validate_controlled_source(name, config)
when 'orcid' then validate_orcid(name, config, parent_name)
end
end

def validate_controlled_source(name, config)
return if config['authority'].present? || config['values'].present?
@errors << t('controlled_without_source', property: name)
end

# An orcid sub-property must not declare an option source (it is a
# free-text iD, not a controlled vocabulary). Its optional `badge_for:`
# must name a sibling sub-property on the same compound parent — not
# itself, and not a property that lives outside the parent.
def validate_orcid(name, config, parent_name)
if config['authority'].present? || config['values'].present?
@errors << t('orcid_with_option_source',
property: name,
default: "Sub-property `#{name}` declares type: orcid and must not set authority: or values:.")
end
validate_badge_for(name, config, parent_name)
end

def validate_badge_for(name, config, parent_name)
badge_for = config['badge_for'].to_s
return if badge_for.blank?

if badge_for == name.to_s
@errors << t('orcid_badge_for_self',
property: name,
default: "Sub-property `#{name}` declares badge_for: pointing at itself.")
return
end

target = properties[badge_for]
return if target.is_a?(Hash) && target['subproperty_of'].to_s == parent_name

@errors << t('orcid_badge_for_unknown_sibling',
property: name, target: badge_for, parent: parent_name,
default: "Sub-property `#{name}` declares badge_for: `#{badge_for}`, " \
"which is not a sibling sub-property of `#{parent_name}`.")
end

# A top-level `indexing:` on a parent would point the catalog at a
# `<compound>_tesim` field the indexer never writes; indexing is declared
# per subproperty.
Expand Down
Loading
Loading