From 294f5265d02cb9e0a7e7d081f8bb1660f4ef7e5d Mon Sep 17 00:00:00 2001 From: Shana Moore Date: Mon, 8 Jun 2026 15:13:12 -0700 Subject: [PATCH 01/21] Recognize orcid and badge_for in compound schema CompoundSchema now threads a sub-property's badge_for: declaration through into its normalized spec hash, alongside the existing type, authority, index_keys, etc. Consumers can read both type: orcid and the badge_for: sibling reference straight off the spec without per-field Ruby. The schema service stays the single source of truth for sub-property shape; later commits read this through to render and validate. --- app/services/hyrax/compound_schema.rb | 3 +- spec/services/hyrax/compound_schema_spec.rb | 43 ++++++++++++++++++++- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/app/services/hyrax/compound_schema.rb b/app/services/hyrax/compound_schema.rb index aede55fc9d..d6f21a96b3 100644 --- a/app/services/hyrax/compound_schema.rb +++ b/app/services/hyrax/compound_schema.rb @@ -251,7 +251,8 @@ 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 } end # Whether the compound itself is required (at least one row must exist). diff --git a/spec/services/hyrax/compound_schema_spec.rb b/spec/services/hyrax/compound_schema_spec.rb index 88d42e07ef..8bcb1205f4 100644 --- a/spec/services/hyrax/compound_schema_spec.rb +++ b/spec/services/hyrax/compound_schema_spec.rb @@ -192,11 +192,11 @@ def self.name definition = schema.definition_for(:contributors) expect(definition[:subproperties]['role_label']) .to eq(type: 'controlled', authority: 'contributor_role', values: nil, index_keys: [], - display: false, required: false, group: 'role', cols: 6, as: nil) + display: false, required: false, group: 'role', cols: 6, as: nil, badge_for: nil) expect(definition[:subproperties]['given_name']) .to eq(type: 'string', authority: nil, values: nil, index_keys: %w[contributors_given_name_sim contributors_given_name_tesim], - display: true, required: false, group: 'identity', cols: 6, as: nil) + display: true, required: false, group: 'identity', cols: 6, as: nil, badge_for: nil) end it 'reads per-sub-property index_keys (literal Solr field names)' do @@ -236,6 +236,45 @@ def self.name end end + describe 'orcid sub-property + badge_for' do + # A compound that carries a name + an orcid sub-property bound to the name + # via `badge_for:`. The schema service should pass `type` and `badge_for` + # through to the normalized spec untouched; the consumers (renderer, view, + # validator) read them from there. + let(:badge_resource_class) do + Class.new(Hyrax::Resource) do + def self.name + 'TestResourceWithOrcidCompound' + end + + attribute :participants, + Valkyrie::Types::Array.of(Dry::Types['hash']).meta( + subproperties: { + 'participant_name' => { 'type' => 'string' }, + 'participant_orcid' => { 'type' => 'orcid', 'badge_for' => 'participant_name' } + } + ) + end + end + + subject(:badge_schema) { described_class.for(badge_resource_class) } + + it 'preserves type: orcid as a sub-property type' do + expect(badge_schema.definition_for(:participants)[:subproperties]['participant_orcid'][:type]) + .to eq('orcid') + end + + it 'preserves badge_for: as a sibling reference' do + expect(badge_schema.definition_for(:participants)[:subproperties]['participant_orcid'][:badge_for]) + .to eq('participant_name') + end + + it 'leaves badge_for nil on sub-properties that do not declare it' do + expect(badge_schema.definition_for(:participants)[:subproperties]['participant_name'][:badge_for]) + .to be_nil + end + end + describe '.for' do it 'builds from a resource instance' do expect(described_class.for(resource_class.new).compound_names) From 855bcb7b1c763b181c25c41bdc680ec0985f909e Mon Sep 17 00:00:00 2001 From: Shana Moore Date: Mon, 8 Jun 2026 15:15:23 -0700 Subject: [PATCH 02/21] Add orcid_badge helper for compound rendering orcid_badge(value, name:) returns a small linked ORCID iD badge targeting https://orcid.org/. It extracts the bare iD from either the bare form or the URL form via the existing Hyrax::OrcidValidator, and folds the sibling's name into the accessibility label so screen readers and tooltips say "ORCID iD for ". The helper carries explicit i18n defaults so it works before its locale keys are defined, keeping each commit self-contained. --- app/helpers/hyrax/compound_fields_helper.rb | 29 ++++++++++++ .../hyrax/compound_fields_helper_spec.rb | 46 +++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/app/helpers/hyrax/compound_fields_helper.rb b/app/helpers/hyrax/compound_fields_helper.rb index effc34590e..96073a44a9 100644 --- a/app/helpers/hyrax/compound_fields_helper.rb +++ b/app/helpers/hyrax/compound_fields_helper.rb @@ -126,6 +126,35 @@ 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/`; 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 + link_to "https://orcid.org/#{bare_id}", + class: 'hyrax-compound-orcid-badge', + target: '_blank', + rel: 'noopener noreferrer', + 'aria-label' => label, + title: label do + image_tag('orcid.png', alt: alt) + end + end + private # Options from a QA local authority. Uses {Hyrax::TolerantSelectService} so diff --git a/spec/helpers/hyrax/compound_fields_helper_spec.rb b/spec/helpers/hyrax/compound_fields_helper_spec.rb index e68eefb27d..8233ad1e81 100644 --- a/spec/helpers/hyrax/compound_fields_helper_spec.rb +++ b/spec/helpers/hyrax/compound_fields_helper_spec.rb @@ -129,4 +129,50 @@ def initialize(solr_document, relationships) end end end + + describe '#orcid_badge' do + let(:bare_id) { '0000-0002-2385-985X' } + + it 'returns nil when the value is blank' do + expect(helper.orcid_badge(nil)).to be_nil + expect(helper.orcid_badge('')).to be_nil + end + + it 'returns nil for an unparseable value' do + expect(helper.orcid_badge('not-an-orcid')).to be_nil + end + + it 'links to https://orcid.org/ with the bare iD when given the bare form' do + html = helper.orcid_badge(bare_id) + expect(html).to match(%r{href="https://orcid\.org/#{bare_id}"}) + end + + it 'links to https://orcid.org/ when given the full URL form' do + html = helper.orcid_badge("https://orcid.org/#{bare_id}") + expect(html).to match(%r{href="https://orcid\.org/#{bare_id}"}) + end + + it 'opens in a new tab with rel=noopener noreferrer' do + html = helper.orcid_badge(bare_id) + expect(html).to include('target="_blank"') + expect(html).to include('rel="noopener noreferrer"') + end + + it 'uses the sibling name in the aria-label and title when provided' do + html = helper.orcid_badge(bare_id, name: 'Hosseini, Mohammad') + expect(html).to include('aria-label="ORCID iD for Hosseini, Mohammad"') + expect(html).to include('title="ORCID iD for Hosseini, Mohammad"') + end + + it 'falls back to the plain alt text when no sibling name is given' do + html = helper.orcid_badge(bare_id) + expect(html).to include('aria-label="ORCID iD"') + end + + it 'renders the orcid.png image with an alt attribute' do + html = helper.orcid_badge(bare_id) + expect(html).to match(/]*alt="ORCID iD"/) + expect(html).to match(/]*src="[^"]*orcid[^"]*\.png"/) + end + end end From 2566f8893d770f5f95e02c0d4b36fb13caa1f57c Mon Sep 17 00:00:00 2001 From: Shana Moore Date: Mon, 8 Jun 2026 15:18:53 -0700 Subject: [PATCH 03/21] Inline orcid badge next to sibling in show CompoundAttributeRenderer now consumes a type: orcid sub-property whose badge_for: names a sibling that has a value in the same row, appending the linked ORCID badge inside the sibling's value markup and omitting the orcid as its own labeled row. An orcid with no badge_for: (or whose target is blank in that row) falls back to rendering standalone via the value_markup case. This matches Zenodo's "name [iD]" treatment exactly while leaving the existing sub-property dispatch and the surrounding compound markup untouched. --- .../stylesheets/hyrax/_compound_metadata.scss | 14 +++++ .../renderers/compound_attribute_renderer.rb | 53 +++++++++++++--- .../compound_attribute_renderer_spec.rb | 61 +++++++++++++++++++ 3 files changed, 121 insertions(+), 7 deletions(-) diff --git a/app/assets/stylesheets/hyrax/_compound_metadata.scss b/app/assets/stylesheets/hyrax/_compound_metadata.scss index ad1c9cdd06..a296017410 100644 --- a/app/assets/stylesheets/hyrax/_compound_metadata.scss +++ b/app/assets/stylesheets/hyrax/_compound_metadata.scss @@ -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; + vertical-align: -0.125em; + } +} diff --git a/app/renderers/hyrax/renderers/compound_attribute_renderer.rb b/app/renderers/hyrax/renderers/compound_attribute_renderer.rb index 4807632b0a..8ffda62db1 100644 --- a/app/renderers/hyrax/renderers/compound_attribute_renderer.rb +++ b/app/renderers/hyrax/renderers/compound_attribute_renderer.rb @@ -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..` 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] @@ -51,21 +54,33 @@ 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 %(
#{items}
) 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 %(
) + %(#{label_html}: ) + - %(#{value_markup(sub_property, value)}) + + %(#{value_html}) + %(
) 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. def value_markup(sub_property, value) return ERB::Util.h(display_value(sub_property, value)) if value.blank? @@ -76,11 +91,35 @@ 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)) end end + # For each row, decide which `type: orcid` sub-properties attach inline + # to a sibling rather than render as their own row. Returns + # `{ skip: [...], attach: { => } }`. + # 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 + { skip: skip, attach: attach } + end + # A controlled value displays its authority/value-list term. When the # stored value is itself a linkable URI (e.g. a rights-statement or license # URI), link the term to that URI — mirroring the ordinary rights/license diff --git a/spec/renderers/hyrax/renderers/compound_attribute_renderer_spec.rb b/spec/renderers/hyrax/renderers/compound_attribute_renderer_spec.rb index 8c4fada149..27cdee1a68 100644 --- a/spec/renderers/hyrax/renderers/compound_attribute_renderer_spec.rb +++ b/spec/renderers/hyrax/renderers/compound_attribute_renderer_spec.rb @@ -120,6 +120,67 @@ end end + describe 'orcid sub-property' do + let(:bare_id) { '0000-0002-2385-985X' } + + context 'when bound to a sibling via badge_for:' do + let(:values) { [{ 'participant_name' => 'Hosseini, Mohammad', 'participant_orcid' => bare_id }] } + let(:subproperties) do + { 'participant_name' => { type: 'string', authority: nil, values: nil, badge_for: nil }, + 'participant_orcid' => { type: 'orcid', authority: nil, values: nil, badge_for: 'participant_name' } } + end + let(:renderer) { described_class.new(:participants, values, label: 'Participants', html_dl: true, subproperties: subproperties) } + + it 'appends the badge inside the sibling sub-property value' do + markup = renderer.render_dl_row + value_block = markup[/hyrax-compound-subproperty-value">.*?<\/span>/m, 0] + expect(value_block).to include('Hosseini, Mohammad') + expect(value_block).to include('hyrax-compound-orcid-badge') + expect(value_block).to include("https://orcid.org/#{bare_id}") + end + + it 'does not render the orcid as its own sub-property row' do + markup = renderer.render_dl_row + expect(markup).not_to match(/Participant orcid:/) + end + + it 'uses the sibling display value in the badge aria-label' do + markup = renderer.render_dl_row + expect(markup).to include('aria-label="ORCID iD for Hosseini, Mohammad"') + end + end + + context 'when the badge_for: target is blank in this row' do + let(:values) { [{ 'participant_orcid' => bare_id }] } + let(:subproperties) do + { 'participant_name' => { type: 'string', authority: nil, values: nil, badge_for: nil }, + 'participant_orcid' => { type: 'orcid', authority: nil, values: nil, badge_for: 'participant_name' } } + end + let(:renderer) { described_class.new(:participants, values, label: 'Participants', html_dl: true, subproperties: subproperties) } + + it 'falls back to rendering the orcid as its own sub-property row' do + markup = renderer.render_dl_row + expect(markup).to match(/Participant orcid:/) + expect(markup).to include("https://orcid.org/#{bare_id}") + end + end + + context 'when no badge_for: is declared' do + let(:values) { [{ 'participant_name' => 'Ada', 'participant_orcid' => bare_id }] } + let(:subproperties) do + { 'participant_name' => { type: 'string', authority: nil, values: nil, badge_for: nil }, + 'participant_orcid' => { type: 'orcid', authority: nil, values: nil, badge_for: nil } } + end + let(:renderer) { described_class.new(:participants, values, label: 'Participants', html_dl: true, subproperties: subproperties) } + + it 'renders the orcid as its own sub-property row' do + markup = renderer.render_dl_row + expect(markup).to match(/Participant orcid:/) + expect(markup).to include("https://orcid.org/#{bare_id}") + end + end + end + describe 'work_or_url sub-property' do let(:subproperties) { { 'related_item' => { type: 'work_or_url', authority: nil, values: nil } } } From 641f40bb9f2a5a048f1822f83a343f6347125783 Mon Sep 17 00:00:00 2001 From: Shana Moore Date: Mon, 8 Jun 2026 15:19:22 -0700 Subject: [PATCH 04/21] Add orcid form input to compound row A new when 'orcid' case in the shared compound row partial renders a text input with the bare-iD placeholder, accepting either the 0000-0000-0000-0000 form or the full https://orcid.org/ URL. The subsequent validator normalizes either form on save. The else branch (string default) is unchanged; orcid plugs in through the same per-type dispatch as the other sub-property types. --- app/views/hyrax/compounds/_compound_row.html.erb | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/app/views/hyrax/compounds/_compound_row.html.erb b/app/views/hyrax/compounds/_compound_row.html.erb index b771fa36d3..ce370a22d4 100644 --- a/app/views/hyrax/compounds/_compound_row.html.erb +++ b/app/views/hyrax/compounds/_compound_row.html.erb @@ -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] %> @@ -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. %> + <%= 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. %> From eb53303e67ec6f81419a033f6894dcc839b5470c Mon Sep 17 00:00:00 2001 From: Shana Moore Date: Mon, 8 Jun 2026 15:21:20 -0700 Subject: [PATCH 05/21] Validate orcid sub-property format on save Hyrax::OrcidSubpropertyValidator is a record-level validator that walks every compound row on a resource form, finds the type: orcid sub-properties off the schema, and rejects any non-blank value that does not match Hyrax::OrcidValidator::ORCID_REGEXP. Blank values are allowed (required-ness lives in CompoundEntryValidator). Errors land on :base with the same compound-naming convention CompoundEntryValidator uses, so work and collection forms render them cleanly. The validator is wired into ResourceForm alongside CompoundEntryValidator. --- app/forms/hyrax/forms/resource_form.rb | 1 + .../hyrax/orcid_subproperty_validator.rb | 65 +++++++++++ .../hyrax/orcid_subproperty_validator_spec.rb | 109 ++++++++++++++++++ 3 files changed, 175 insertions(+) create mode 100644 app/validators/hyrax/orcid_subproperty_validator.rb create mode 100644 spec/validators/hyrax/orcid_subproperty_validator_spec.rb diff --git a/app/forms/hyrax/forms/resource_form.rb b/app/forms/hyrax/forms/resource_form.rb index 2d7a65d572..bfd3023ba0 100644 --- a/app/forms/hyrax/forms/resource_form.rb +++ b/app/forms/hyrax/forms/resource_form.rb @@ -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 ## diff --git a/app/validators/hyrax/orcid_subproperty_validator.rb b/app/validators/hyrax/orcid_subproperty_validator.rb new file mode 100644 index 0000000000..911bb3a83b --- /dev/null +++ b/app/validators/hyrax/orcid_subproperty_validator.rb @@ -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 diff --git a/spec/validators/hyrax/orcid_subproperty_validator_spec.rb b/spec/validators/hyrax/orcid_subproperty_validator_spec.rb new file mode 100644 index 0000000000..b6a7052db3 --- /dev/null +++ b/spec/validators/hyrax/orcid_subproperty_validator_spec.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +RSpec.describe Hyrax::OrcidSubpropertyValidator do + let(:record_class) do + Class.new do + include ActiveModel::Validations + attr_accessor :participants + validates_with Hyrax::OrcidSubpropertyValidator + end + end + let(:record) { record_class.new.tap { |r| r.participants = entries } } + let(:entries) { [] } + + # A schema with one orcid sub-property on the `participants` compound. The + # validator selects orcid sub-properties off the schema, so the spec stubs + # CompoundSchema.for to return a definition with one. + let(:definition) do + { required: false, + subproperties: { + 'participant_name' => { type: 'string' }, + 'participant_orcid' => { type: 'orcid', badge_for: 'participant_name' } + } } + end + let(:schema) { instance_double(Hyrax::CompoundSchema, definitions: { participants: definition }) } + + before do + allow(Hyrax::CompoundSchema).to receive(:for).and_return(schema) + end + + context 'with no rows' do + it 'is valid' do + record.valid? + expect(record.errors[:base]).to be_empty + end + end + + context 'with a row that omits the orcid' do + let(:entries) { [{ 'participant_name' => 'Hosseini, Mohammad' }] } + + it 'is valid (orcid is optional)' do + record.valid? + expect(record.errors[:base]).to be_empty + end + end + + context 'with a row that has a blank orcid' do + let(:entries) { [{ 'participant_name' => 'Hosseini, Mohammad', 'participant_orcid' => '' }] } + + it 'is valid' do + record.valid? + expect(record.errors[:base]).to be_empty + end + end + + context 'with a row whose orcid is in the bare-iD form' do + let(:entries) { [{ 'participant_name' => 'X', 'participant_orcid' => '0000-0002-2385-985X' }] } + + it 'is valid' do + record.valid? + expect(record.errors[:base]).to be_empty + end + end + + context 'with a row whose orcid is in the full URL form' do + let(:entries) { [{ 'participant_name' => 'X', 'participant_orcid' => 'https://orcid.org/0000-0002-2385-985X' }] } + + it 'is valid' do + record.valid? + expect(record.errors[:base]).to be_empty + end + end + + context 'with a row whose orcid is malformed' do + let(:entries) { [{ 'participant_name' => 'X', 'participant_orcid' => 'not-an-id' }] } + + it 'adds one base error naming the compound and the sub-property' do + I18n.with_locale(:en) do + record.valid? + expect(record.errors[:base].size).to eq(1) + expect(record.errors[:base].first).to include('Participants').and include('Participant orcid') + end + end + end + + context 'with multiple rows where only one orcid is malformed' do + let(:entries) do + [{ 'participant_name' => 'Ok', 'participant_orcid' => '0000-0001-2345-6789' }, + { 'participant_name' => 'Bad', 'participant_orcid' => 'still-not-valid' }] + end + + it 'adds an error only for the malformed row' do + record.valid? + expect(record.errors[:base].size).to eq(1) + end + end + + context 'when a compound has no orcid sub-properties' do + let(:definition) do + { required: false, + subproperties: { 'plain' => { type: 'string' } } } + end + let(:entries) { [{ 'plain' => 'whatever' }] } + + it 'does not touch the row' do + record.valid? + expect(record.errors[:base]).to be_empty + end + end +end From 17f5e45d4a199b46304682539ad5b53709c33ee2 Mon Sep 17 00:00:00 2001 From: Shana Moore Date: Mon, 8 Jun 2026 15:22:42 -0700 Subject: [PATCH 06/21] Validate orcid declarations in m3 profile CompoundValidator now rejects type: orcid sub-properties that also declare authority: or values: (orcid is a free-text iD, not a controlled vocabulary), and any badge_for: that points at itself or at a property that is not a sibling sub-property of the same compound parent. A misconfigured m3 profile now fails at save with a clear message instead of producing a misrouted or unrenderable badge. --- .../compound_validator.rb | 42 +++++- .../compound_validator_spec.rb | 123 ++++++++++++++++++ 2 files changed, 163 insertions(+), 2 deletions(-) diff --git a/app/services/hyrax/flexible_schema_validators/compound_validator.rb b/app/services/hyrax/flexible_schema_validators/compound_validator.rb index 563756838c..615703f3e3 100644 --- a/app/services/hyrax/flexible_schema_validators/compound_validator.rb +++ b/app/services/hyrax/flexible_schema_validators/compound_validator.rb @@ -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 # `_tesim` field the indexer never writes; indexing is declared # per subproperty. diff --git a/spec/services/hyrax/flexible_schema_validators/compound_validator_spec.rb b/spec/services/hyrax/flexible_schema_validators/compound_validator_spec.rb index e1bd0e5cfc..3158a56c38 100644 --- a/spec/services/hyrax/flexible_schema_validators/compound_validator_spec.rb +++ b/spec/services/hyrax/flexible_schema_validators/compound_validator_spec.rb @@ -115,5 +115,128 @@ def t(key, **opts) expect(errors).to be_empty end end + + context 'with a well-formed orcid sub-property bound to a sibling' do + let(:profile) do + { + 'properties' => { + 'participants' => { 'type' => 'hash' }, + 'participant_name' => { 'type' => 'string', 'subproperty_of' => 'participants' }, + 'participant_orcid' => { 'type' => 'orcid', 'subproperty_of' => 'participants', 'badge_for' => 'participant_name' } + } + } + end + + it 'is valid' do + validator.validate! + expect(errors).to be_empty + end + end + + context 'with a well-formed orcid sub-property and no badge_for' do + let(:profile) do + { + 'properties' => { + 'participants' => { 'type' => 'hash' }, + 'participant_orcid' => { 'type' => 'orcid', 'subproperty_of' => 'participants' } + } + } + end + + it 'is valid' do + validator.validate! + expect(errors).to be_empty + end + end + + context 'with an orcid sub-property that also declares values:' do + let(:profile) do + { + 'properties' => { + 'participants' => { 'type' => 'hash' }, + 'participant_orcid' => { 'type' => 'orcid', 'subproperty_of' => 'participants', + 'values' => %w[Author Editor] } + } + } + end + + it 'records an error' do + validator.validate! + expect(errors.join("\n")).to include('participant_orcid') + expect(errors.join("\n")).to match(/authority|values/i) + end + end + + context 'with an orcid sub-property that also declares authority:' do + let(:profile) do + { + 'properties' => { + 'participants' => { 'type' => 'hash' }, + 'participant_orcid' => { 'type' => 'orcid', 'subproperty_of' => 'participants', + 'authority' => 'participant_role' } + } + } + end + + it 'records an error' do + validator.validate! + expect(errors.join("\n")).to include('participant_orcid') + end + end + + context 'with an orcid sub-property whose badge_for points at itself' do + let(:profile) do + { + 'properties' => { + 'participants' => { 'type' => 'hash' }, + 'participant_orcid' => { 'type' => 'orcid', 'subproperty_of' => 'participants', + 'badge_for' => 'participant_orcid' } + } + } + end + + it 'records an error' do + validator.validate! + expect(errors.join("\n")).to include('participant_orcid') + expect(errors.join("\n")).to match(/itself/i) + end + end + + context 'with an orcid sub-property whose badge_for points at a non-sibling' do + let(:profile) do + { + 'properties' => { + 'participants' => { 'type' => 'hash' }, + 'participant_orcid' => { 'type' => 'orcid', 'subproperty_of' => 'participants', + 'badge_for' => 'unrelated_property' }, + # Exists at the top level but is not a sibling of participant_orcid + 'unrelated_property' => { 'type' => 'string' } + } + } + end + + it 'records an error' do + validator.validate! + expect(errors.join("\n")).to include('participant_orcid') + expect(errors.join("\n")).to include('unrelated_property') + end + end + + context 'with an orcid sub-property whose badge_for points at a missing sibling' do + let(:profile) do + { + 'properties' => { + 'participants' => { 'type' => 'hash' }, + 'participant_orcid' => { 'type' => 'orcid', 'subproperty_of' => 'participants', + 'badge_for' => 'missing_sibling' } + } + } + end + + it 'records an error' do + validator.validate! + expect(errors.join("\n")).to include('missing_sibling') + end + end end end From fcc45ba520b5c40094ed530440cf17994390aea8 Mon Sep 17 00:00:00 2001 From: Shana Moore Date: Mon, 8 Jun 2026 15:23:52 -0700 Subject: [PATCH 07/21] Add participant_orcid to sample participants compound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shipped participants compound now declares a type: orcid sub-property bound to participant_name via badge_for:, so the sample demonstrates the inline-badge treatment out of the box. Mirrored in the non-flex schema (config/metadata/compound_metadata.yaml), the central m3 profile, and the koppie m3 profile — the three places PR #7479 ships participants. Dassie does not carry the participants sample and is left untouched. --- .koppie/config/metadata_profiles/m3_profile.yaml | 11 +++++++++++ config/metadata/compound_metadata.yaml | 13 +++++++++++++ config/metadata_profiles/m3_profile.yaml | 11 +++++++++++ 3 files changed, 35 insertions(+) diff --git a/.koppie/config/metadata_profiles/m3_profile.yaml b/.koppie/config/metadata_profiles/m3_profile.yaml index c8f77ec9fb..6b73c55f79 100644 --- a/.koppie/config/metadata_profiles/m3_profile.yaml +++ b/.koppie/config/metadata_profiles/m3_profile.yaml @@ -979,6 +979,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: diff --git a/config/metadata/compound_metadata.yaml b/config/metadata/compound_metadata.yaml index 6725d95b44..1cdc6a7f23 100644 --- a/config/metadata/compound_metadata.yaml +++ b/config/metadata/compound_metadata.yaml @@ -70,6 +70,19 @@ attributes: index_keys: [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. Accepts either the bare iD + # (0000-0000-0000-0000) or the full https://orcid.org/ URL; the validator + # rejects anything else. + participant_orcid: + type: orcid + subproperty_of: participants + group: identity + badge_for: participant_name + index_keys: [participant_orcid_ssim] + form: + cols: 4 # A typed identifier: an open-entry value plus a controlled identifier type. # Named `identifiers` (plural) to avoid colliding with the scalar `identifier` diff --git a/config/metadata_profiles/m3_profile.yaml b/config/metadata_profiles/m3_profile.yaml index d9f3494e53..7bea07d25a 100644 --- a/config/metadata_profiles/m3_profile.yaml +++ b/config/metadata_profiles/m3_profile.yaml @@ -1005,6 +1005,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: From 01786082ab69c015f7e9b12f79a5357147ed19a0 Mon Sep 17 00:00:00 2001 From: Shana Moore Date: Mon, 8 Jun 2026 15:25:31 -0700 Subject: [PATCH 08/21] Add English orcid compound locale keys The badge helper, validator messages, form placeholder, and the m3 profile compound validator now read through Hyrax's existing i18n keys. This commit adds the English copies: - hyrax.compound_fields.orcid.{badge_alt,badge_aria, invalid,placeholder} - hyrax.flexible_schema_validators.compound_validator.errors. {orcid_with_option_source,orcid_badge_for_self, orcid_badge_for_unknown_sibling} The participant_orcid sub-property label is intentionally left to the humanized fallback ("Participant orcid"), matching the treatment of the existing participant_title sub-property. --- config/locales/hyrax.en.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/config/locales/hyrax.en.yml b/config/locales/hyrax.en.yml index 1398ec48b2..6da063c36b 100644 --- a/config/locales/hyrax.en.yml +++ b/config/locales/hyrax.en.yml @@ -870,6 +870,11 @@ en: title: Title participant_name: Name participant_role: Role + orcid: + 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 @@ -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" From d213ad9fcb84e72d96ad4529b7c359c364e4d310 Mon Sep 17 00:00:00 2001 From: Shana Moore Date: Mon, 8 Jun 2026 15:30:39 -0700 Subject: [PATCH 09/21] Translate orcid compound keys to six locales de, es, fr, it, pt-BR, and zh each pick up a compound_fields.orcid block with locale-appropriate translations for badge_alt, badge_aria, invalid, and placeholder. The non-English locales otherwise carry no compound_fields keys (PR #7479's add to those locales is still pending), so this commit adds the parent path alongside the orcid sub-keys. participant_orcid is intentionally not overridden; it humanizes to "Participant orcid" in every locale. --- config/locales/hyrax.de.yml | 6 ++++++ config/locales/hyrax.es.yml | 6 ++++++ config/locales/hyrax.fr.yml | 6 ++++++ config/locales/hyrax.it.yml | 6 ++++++ config/locales/hyrax.pt-BR.yml | 6 ++++++ config/locales/hyrax.zh.yml | 6 ++++++ 6 files changed, 36 insertions(+) diff --git a/config/locales/hyrax.de.yml b/config/locales/hyrax.de.yml index f8e7dd2604..b432114407 100644 --- a/config/locales/hyrax.de.yml +++ b/config/locales/hyrax.de.yml @@ -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: "Sortieren nach:" + 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 diff --git a/config/locales/hyrax.es.yml b/config/locales/hyrax.es.yml index 851845b82e..b7a20b824e 100644 --- a/config/locales/hyrax.es.yml +++ b/config/locales/hyrax.es.yml @@ -870,6 +870,12 @@ es: number_of_results_to_display_per_page: Número de resultados a mostrar por página results_per_page: "Resultados por página:" sort_by_html: "Tipo:" + compound_fields: + orcid: + badge_alt: ORCID iD + badge_aria: ORCID iD de %{name} + invalid: "%{compound} > %{field}: \"%{value}\" no es un ORCID iD válido" + placeholder: 0000-0000-0000-0000 contact_form: button_label: Enviar email_label: Tu Correo Electrónico diff --git a/config/locales/hyrax.fr.yml b/config/locales/hyrax.fr.yml index dd030b9502..6392aa1800 100644 --- a/config/locales/hyrax.fr.yml +++ b/config/locales/hyrax.fr.yml @@ -871,6 +871,12 @@ fr: number_of_results_to_display_per_page: Nombre de résultats à afficher par page results_per_page: "Résultats par page:" sort_by_html: "Trier par:" + compound_fields: + orcid: + badge_alt: Identifiant ORCID + badge_aria: Identifiant ORCID de %{name} + invalid: "%{compound} > %{field} : \"%{value}\" n'est pas un identifiant ORCID valide" + placeholder: 0000-0000-0000-0000 contact_form: button_label: Envoyer email_label: Votre Email diff --git a/config/locales/hyrax.it.yml b/config/locales/hyrax.it.yml index 5152353b2e..b8be269201 100644 --- a/config/locales/hyrax.it.yml +++ b/config/locales/hyrax.it.yml @@ -870,6 +870,12 @@ it: number_of_results_to_display_per_page: Numero di risultati da visualizzare per pagina results_per_page: "Risultati per la pagina:" sort_by_html: "Ordina per:" + compound_fields: + orcid: + badge_alt: ORCID iD + badge_aria: ORCID iD di %{name} + invalid: "%{compound} > %{field}: \"%{value}\" non è un ORCID iD valido" + placeholder: 0000-0000-0000-0000 contact_form: button_label: Inviare email_label: La tua email diff --git a/config/locales/hyrax.pt-BR.yml b/config/locales/hyrax.pt-BR.yml index c376750641..70c2b313ca 100644 --- a/config/locales/hyrax.pt-BR.yml +++ b/config/locales/hyrax.pt-BR.yml @@ -863,6 +863,12 @@ pt-BR: number_of_results_to_display_per_page: Número de resultados exibidos por página results_per_page: "Resultados por página:" sort_by_html: "Ordenar por:" + compound_fields: + orcid: + badge_alt: ORCID iD + badge_aria: ORCID iD de %{name} + invalid: "%{compound} > %{field}: \"%{value}\" não é um ORCID iD válido" + placeholder: 0000-0000-0000-0000 contact_form: button_label: Enviar email_label: Seu email diff --git a/config/locales/hyrax.zh.yml b/config/locales/hyrax.zh.yml index 2657c06f01..2cf5541c80 100644 --- a/config/locales/hyrax.zh.yml +++ b/config/locales/hyrax.zh.yml @@ -868,6 +868,12 @@ zh: number_of_results_to_display_per_page: 数量的结果显示,每页 results_per_page: 每页结果 sort_by_html: "<跨>排序:" + compound_fields: + orcid: + badge_alt: ORCID iD + badge_aria: "%{name} 的 ORCID iD" + invalid: "%{compound} > %{field}:\"%{value}\" 不是有效的 ORCID iD" + placeholder: 0000-0000-0000-0000 contact_form: button_label: 发送 email_label: 您的邮件 From 9ff128b1b8e0bec441b19e37a87f58d0d94e3155 Mon Sep 17 00:00:00 2001 From: Shana Moore Date: Mon, 8 Jun 2026 15:31:23 -0700 Subject: [PATCH 10/21] Document type: orcid sub-property and badge_for The compound_fields documentation now lists orcid as a supported sub-property type alongside string / url / work_or_url / controlled, and adds a worked example section explaining the badge_for: sibling binding, the show-page behavior, the standalone fallback, the m3 profile validator rules, and the validators on the persisted row. --- documentation/compound_fields.md | 51 ++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/documentation/compound_fields.md b/documentation/compound_fields.md index 6cdce4ca24..5798a12c04 100644 --- a/documentation/compound_fields.md +++ b/documentation/compound_fields.md @@ -116,6 +116,7 @@ textarea). Supported `type:` values: | `url` | url input → auto-linked on show | The stored value is rendered as a clickable `` on show pages (matching the scalar `render_as: external_link` behavior). | | `work_or_url` | select2 typeahead → linked on show | Searches internal works (via the `compound_works` QA authority, backed by `Hyrax::CompoundWorkPickerBuilder`) **or** accepts a typed external URL. The stored value is a work id or a URL; on show, a work id links to the work's show page with its title (resolved by `Hyrax::CompoundWorkResolver`), a URL is auto-linked. | | `controlled` | `` dropdown | Options come from either an inline `values:` list or a QA local authority named by `authority:` (see below). The row's stored value is preserved even if it is no longer offered, matching the `include_current_value` convention of the ordinary controlled-field partials. | diff --git a/spec/renderers/hyrax/renderers/compound_attribute_renderer_spec.rb b/spec/renderers/hyrax/renderers/compound_attribute_renderer_spec.rb index 27cdee1a68..2be669e850 100644 --- a/spec/renderers/hyrax/renderers/compound_attribute_renderer_spec.rb +++ b/spec/renderers/hyrax/renderers/compound_attribute_renderer_spec.rb @@ -120,6 +120,29 @@ end end + describe 'string sub-property with search_field' do + let(:values) { [{ 'participant_name' => 'Hosseini, Mohammad' }] } + let(:subproperties) do + { 'participant_name' => { type: 'string', authority: nil, values: nil, + search_field: 'participant_name_sim' } } + end + let(:renderer) { described_class.new(:participants, values, label: 'Participants', html_dl: true, subproperties: subproperties) } + + it 'wraps the value in a link to the catalog search filtered by the named Solr field' do + markup = renderer.render_dl_row + expect(markup).to match(%r{ 'Hosseini, Mohammad' }], + label: 'Participants', html_dl: true, + subproperties: { 'participant_name' => { type: 'string', search_field: nil } }) + expect(r.render_dl_row).not_to match(%r{ Date: Tue, 9 Jun 2026 11:39:35 -0700 Subject: [PATCH 21/21] Use non-block link_to in orcid_badge helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dassie + sirenia run Rails 6.1, where `link_to ... do ... end` calls `capture(&block)` and requires `output_buffer=` on the receiver. The renderer that includes `Hyrax::CompoundFieldsHelper` is a plain Ruby object with no `output_buffer`, so the block form raises `NoMethodError: undefined method output_buffer=`. (Rails 7 silently tolerates the missing setter, which is why koppie and allinson were green.) Switch to `ActionController::Base.helpers.link_to(content, url, opts)` — a view-equivalent proxy that already carries `output_buffer`, used in the non-block form so no `capture` runs. `image_tag` already used the same proxy for asset path resolution, so the helper is now uniformly view-context-free. --- app/helpers/hyrax/compound_fields_helper.rb | 31 +++++++++++---------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/app/helpers/hyrax/compound_fields_helper.rb b/app/helpers/hyrax/compound_fields_helper.rb index 19ba43b825..fd67e6628c 100644 --- a/app/helpers/hyrax/compound_fields_helper.rb +++ b/app/helpers/hyrax/compound_fields_helper.rb @@ -145,20 +145,23 @@ def orcid_badge(value, name: nil) 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 + # `ActionController::Base.helpers` is a view-equivalent proxy that + # resolves asset paths and tag helpers without needing the caller to + # carry `output_buffer`. Using it for both `image_tag` and `link_to` + # keeps the helper safe in either context: a view (where `self` already + # has output_buffer) or `Hyrax::Renderers::CompoundAttributeRenderer` + # (a non-view Ruby object). The non-block `link_to(content, url, opts)` + # form is required on Rails 6.1 — the block form calls `capture` which + # would raise `undefined method output_buffer=` on the renderer. + helpers = ActionController::Base.helpers + img = helpers.image_tag('orcid.png', alt: alt) + helpers.link_to(img, + "https://orcid.org/#{bare_id}", + class: 'hyrax-compound-orcid-badge', + target: '_blank', + rel: 'noopener noreferrer', + 'aria-label' => label, + title: label) end private