Skip to content
Open
Show file tree
Hide file tree
Changes from 13 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
14 changes: 14 additions & 0 deletions app/assets/stylesheets/hyrax/_compound_metadata.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
width: auto;
Comment thread
ShanaLMoore marked this conversation as resolved.
Outdated
vertical-align: -0.125em;
}
}
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
70 changes: 62 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,46 @@ def value_markup(sub_property, value)
work_or_url_markup(value)
when 'controlled'
controlled_markup(sub_property, value)
when 'orcid'
orcid_badge(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 with no `badge_for:` (or whose target is blank in this row)
# is left in place and renders standalone via value_markup.
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?
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
65 changes: 65 additions & 0 deletions app/validators/hyrax/orcid_subproperty_validator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# frozen_string_literal: true

module Hyrax
# Validates every `type: orcid` sub-property value on a compound's persisted
# rows. A blank value is allowed (required-ness lives in
# {Hyrax::CompoundEntryValidator}); a non-blank value must match
# {Hyrax::OrcidValidator::ORCID_REGEXP} in either the bare-iD form
# (`0000-0000-0000-0000`) or the full `https://orcid.org/` URL form.
#
# Adds one error per offending row to `:base`, naming the compound and the
# offending sub-property — same keying convention {Hyrax::CompoundEntryValidator}
# uses so work and collection forms render the message cleanly. See
# documentation/compound_fields.md.
class OrcidSubpropertyValidator < ActiveModel::Validator
def validate(record)
schema = Hyrax::CompoundSchema.for(schema_source(record))
schema.definitions.each do |name, definition|
next unless record.respond_to?(name)
validate_compound(record, name, definition)
end
rescue StandardError => e
Hyrax.logger.debug("OrcidSubpropertyValidator: #{e.message}")
end

private

# The schema declarations live on the underlying resource, not the form
# wrapper: a Reform form exposes it as `model`. Fall back to the record
# itself (e.g. a plain ActiveModel object in unit tests).
def schema_source(record)
record.respond_to?(:model) ? record.model : record
end

def validate_compound(record, name, definition)
orcid_keys = definition[:subproperties].select { |_key, spec| spec[:type].to_s == 'orcid' }.keys
return if orcid_keys.empty?

Array(record.public_send(name)).each do |entry|
next unless entry.is_a?(Hash)
orcid_keys.each do |sub_property|
value = entry[sub_property] || entry[sub_property.to_sym]
next if value.blank?
next if Hyrax::OrcidValidator::ORCID_REGEXP.match?(value.to_s)
record.errors.add(:base, message_for(name, sub_property, value))
end
end
end

def message_for(name, sub_property, value)
I18n.t('hyrax.compound_fields.orcid.invalid',
compound: compound_label(name),
field: subproperty_label(name, sub_property),
value: value,
default: %("#{value}" is not a valid ORCID iD for #{compound_label(name)} > #{subproperty_label(name, sub_property)}.))
end

def compound_label(name)
I18n.t("hyrax.compound_fields.#{name}.label", default: name.to_s.humanize)
end

def subproperty_label(compound_name, sub_property)
I18n.t("hyrax.compound_fields.#{compound_name}.#{sub_property}", default: sub_property.to_s.humanize)
end
end
end
12 changes: 10 additions & 2 deletions app/views/hyrax/compounds/_compound_row.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
row_label_singular - "Contributor", "License", etc.

Each sub-property renders according to its declared `type:` (string,
controlled, url, work_or_url). db_table / geocode are future types; the
`else` branch is the extension seam.
controlled, url, work_or_url, orcid). db_table / geocode are future types;
the `else` branch is the extension seam.
%>
<% subproperties = definition[:subproperties] %>
<% groups = definition[:groups] %>
Expand Down Expand Up @@ -56,6 +56,14 @@
<%= url_field_tag input_name, value,
id: input_id,
class: 'form-control form-control-sm' %>
<% when 'orcid' %>
<%# A bare iD (0000-0000-0000-0000) or the full https://orcid.org/ URL.
Hyrax::OrcidSubpropertyValidator normalizes either form on save. %>
Comment thread
ShanaLMoore marked this conversation as resolved.
Outdated
<%= text_field_tag input_name, value,
id: input_id,
class: 'form-control form-control-sm',
placeholder: t('hyrax.compound_fields.orcid.placeholder', default: '0000-0000-0000-0000'),
autocomplete: 'off' %>
<% when 'work_or_url' %>
<%# select2 v3 (bound in compound_metadata.js) binds to this
hidden input; `data-label` pre-seeds the displayed text. %>
Expand Down
6 changes: 6 additions & 0 deletions config/locales/hyrax.de.yml
Original file line number Diff line number Diff line change
Expand Up @@ -864,6 +864,12 @@ de:
number_of_results_to_display_per_page: Anzahl der Ergebnisse die pro Seite angezeigt werden
results_per_page: "Ergebnisse pro Seite:"
sort_by_html: "<span>Sortieren nach:</span>"
compound_fields:
orcid:
badge_alt: ORCID iD
badge_aria: ORCID iD von %{name}
invalid: "%{compound} > %{field}: \"%{value}\" ist keine gültige ORCID iD"
placeholder: 0000-0000-0000-0000
contact_form:
button_label: Senden
email_label: Ihre E-Mail
Expand Down
8 changes: 8 additions & 0 deletions config/locales/hyrax.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -870,6 +870,11 @@ en:
title: Title
participant_name: Name
participant_role: Role
orcid:
Comment on lines 869 to +873

@ShanaLMoore ShanaLMoore Jun 9, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Holding on this one. It was intentional decision to NOT add a hyrax.compound_fields.participants.participant_orcid locale override — letting the value humanize to "Participant orcid" in the rare standalone-fallback case (when badge_for: is omitted or the sibling has no value). The common case displays the inline green badge with no row label. Happy to add the label if reviewers prefer the explicit "ORCID iD" copy.

badge_alt: ORCID iD
badge_aria: ORCID iD for %{name}
invalid: "%{compound} > %{field}: \"%{value}\" is not a valid ORCID iD"
placeholder: 0000-0000-0000-0000
identifiers:
label: Identifiers
identifier: Identifier
Expand Down Expand Up @@ -1420,6 +1425,9 @@ en:
unknown_parent: "m3 profile subproperty `%{property}` declares `subproperty_of: %{parent}`, but `%{parent}` is not a declared `type: hash` compound property"
controlled_without_source: "m3 profile compound subproperty `%{property}` is `type: controlled` but declares neither `authority` nor `values`"
top_level_indexing: "m3 profile compound property `%{property}` must not declare top-level `indexing`; declare `indexing` (or `index_keys`) on each subproperty instead"
orcid_with_option_source: "m3 profile compound subproperty `%{property}` is `type: orcid` and must not declare `authority` or `values` (ORCID iDs are free-text, not a controlled vocabulary)"
orcid_badge_for_self: "m3 profile compound subproperty `%{property}` declares `badge_for: %{property}` (it cannot point at itself)"
orcid_badge_for_unknown_sibling: "m3 profile compound subproperty `%{property}` declares `badge_for: %{target}`, but `%{target}` is not a sibling sub-property of `%{parent}`"
redirects_validator:
errors:
invalid_available_on: "m3 profile `redirects` property must be available on at least one work or collection class declared in this profile"
Expand Down
Loading
Loading