Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
29 changes: 27 additions & 2 deletions app/assets/javascripts/hyrax/compound_metadata.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,29 @@
});
}

// Bind select2 to a `controlled` sub-property tagged
// `data-hyrax-compound-controlled` (see _compound_row.html.erb). Unlike the
// work_or_url / linked_record pickers, this binds a NATIVE <select> whose
// full option list is already in the DOM, so select2 filters client-side —
// no ajax. A `multiple` select becomes a tags-style multi picker; select2
// infers `multiple` from the element.
function bindControlledSelects(root) {
if (typeof jQuery === 'undefined' || !jQuery.fn.select2) return;
jQuery(root).find('[data-hyrax-compound-controlled]').each(function() {
var $el = jQuery(this);
if ($el.hasClass('select2-offscreen') || $el.data('select2')) return; // already bound

$el.select2({
width: '100%',
// A single select carries a blank option (include_blank), so
// allowClear gives it an "x"; a multiple has none and needs a
// placeholder to prompt.
allowClear: !$el.prop('multiple'),
placeholder: $el.data('placeholder') || ''
});
});
}

// Show/hide the "Add new" trigger for a creatable linked_record when a
// search returned no matches; stash the typed term to prefill the form.
// `wrapEl` is the [data-hyrax-linked-record] wrapper captured at bind time.
Expand All @@ -86,7 +109,7 @@
// Bind saved rows once the DOM is ready (at script-eval time the form
// inputs don't exist yet, so the select2 would never attach). Covers both a
// fresh load and Turbolinks navigation.
function bindAll() { bindWorkOrUrlInputs(document); }
function bindAll() { bindWorkOrUrlInputs(document); bindControlledSelects(document); }
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bindAll);
} else {
Expand Down Expand Up @@ -264,8 +287,10 @@
}
var html = template.innerHTML.replace(/__INDEX__/g, nextIndex);
rowsHost.insertAdjacentHTML('beforeend', html);
// Bind select2 on any work_or_url / linked_record inputs in the new row.
// Bind select2 on any work_or_url / linked_record inputs and controlled
// typeahead selects in the new row.
bindWorkOrUrlInputs(rowsHost.lastElementChild || rowsHost);
bindControlledSelects(rowsHost.lastElementChild || rowsHost);
section.dataset.nextIndex = String(nextIndex + 1);
});
})();
42 changes: 42 additions & 0 deletions app/assets/stylesheets/hyrax/_compound_metadata.scss
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,45 @@
margin-top: 0.25rem;
}
}

// A `controlled` sub-property with `form: { autocomplete: true }` binds select2
// v3 over the native <select>. select2 copies the control's `.form-control`
// classes onto its own container (a Bootstrap border + the fixed
// `.form-control-sm` height) while rendering its own bordered inner box (a
// "box within a box"), and the fixed height clips a multi-select's chosen pills.
// Flatten the inner box so only the form-control frame shows, and let that frame
// grow to fit the selected values instead of overflowing into the next field.
.hyrax-compound-row {
.select2-container.form-control {
height: auto;
min-height: calc(1.5em + 0.5rem + 2px); // matches .form-control-sm
padding: 0;

.select2-choices, // multi-select
.select2-choice { // single-select
border: 0;
Comment thread
ShanaLMoore marked this conversation as resolved.
Outdated
background: transparent;
box-shadow: none;
min-height: 0;
}
}

// Multi-select: size the empty control to match a sibling .form-control-sm
// input (so Name and Role line up), and keep the chosen pills tidy as they
// wrap and grow the box.
.select2-container-multi.form-control {
.select2-choices {
Comment thread
ShanaLMoore marked this conversation as resolved.
Outdated
padding: 0 0.5rem;
}

.select2-search-field input.select2-input {
Comment thread
ShanaLMoore marked this conversation as resolved.
Outdated
height: calc(1.5em + 0.5rem);
margin: 0;
Comment thread
ShanaLMoore marked this conversation as resolved.
Outdated
line-height: 1.5;
}

.select2-search-choice {
margin: 2px 0 2px 5px;
Comment thread
ShanaLMoore marked this conversation as resolved.
Outdated
}
}
}
59 changes: 46 additions & 13 deletions app/forms/concerns/hyrax/compound_field_behavior.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,34 +56,67 @@ def compound_field_names

# One populator serves every compound (Reform passes the property name as
# `as:`). Builds the replacement array of plain hashes — declared
# sub-property keys only, dropping `_destroy` and all-blank rows.
# sub-property keys only, dropping `_destroy` and all-blank rows. A
# `multiple: true` member with several selected values fans its row out into
# one entry per value (see {#expand_multiple_members}).
def compound_attributes_populator(fragment:, as:, **_options)
name = as.to_s.delete_suffix('_attributes')
return unless respond_to?(name)

allowed = Hyrax::CompoundSchema.for(model).subproperty_keys(name)
public_send(:"#{name}=", build_compound_rows(fragment, allowed))
definition = Hyrax::CompoundSchema.for(model).definition_for(name)
allowed = (definition || {}).fetch(:subproperties, {}).keys
multiple_keys = multiple_subproperty_keys(definition)
public_send(:"#{name}=", build_compound_rows(fragment, allowed, multiple_keys))
end

def build_compound_rows(fragment, allowed_keys)
def build_compound_rows(fragment, allowed_keys, multiple_keys = [])
fragment_pairs(fragment)
.sort_by { |key, _row| key.to_i }
.map { |_key, row| compound_row_from(row, allowed_keys) }
.flat_map { |_key, row| compound_row_from(row, allowed_keys, multiple_keys) }
.compact
end

# Returns nil for a row marked for destruction or whose declared sub-properties
# are all blank, otherwise the persisted hash for that row.
def compound_row_from(row, allowed_keys)
# The sub-property keys declared `form: { multiple: true }` for this
# compound (empty when the definition is unavailable).
def multiple_subproperty_keys(definition)
return [] unless definition.is_a?(Hash)
definition.fetch(:subproperties, {}).select { |_k, spec| spec[:multiple] }.keys
end

# Returns nil for a row marked for destruction or whose declared
# sub-properties are all blank; otherwise an array of one or more persisted
# entry hashes — several when a `multiple` member carries multiple values.
def compound_row_from(row, allowed_keys, multiple_keys = [])
row = row_hash(row)
return nil if %w[true 1].include?(row['_destroy'].to_s)

entry = allowed_keys.each_with_object({}) do |key, memo|
value = row[key]
memo[key] = value.is_a?(String) ? value.strip : value
end
entry = allowed_keys.index_with { |key| normalize_row_value(row[key], multiple_keys.include?(key)) }
return nil if entry.values.all?(&:blank?)
entry

expand_multiple_members(entry, multiple_keys)
end

# A `multiple` member is coerced to a compacted array of stripped, non-blank
# values; any other member keeps its scalar (stripped if a string).
def normalize_row_value(value, multiple)
return Array(value).map { |v| v.is_a?(String) ? v.strip : v }.reject(&:blank?) if multiple

value.is_a?(String) ? value.strip : value
end

# Fan `multiple` members out so each stored entry holds one scalar value per
# such member. With a single multiple member (the common case) this yields
# one entry per selected value; with several it yields their cartesian
# product. A multiple member with no values collapses to a single nil, so a
# row whose only content is its other members is still stored once.
def expand_multiple_members(entry, multiple_keys)
present = multiple_keys.select { |key| entry[key].is_a?(Array) && entry[key].any? }
return [entry.transform_values { |v| v.is_a?(Array) ? nil : v }] if present.empty?

value_lists = present.map { |key| entry[key] }
value_lists.first.product(*value_lists[1..]).map do |combo|
entry.merge(present.zip(combo).to_h)
end
end
end
end
Comment thread
ShanaLMoore marked this conversation as resolved.
18 changes: 11 additions & 7 deletions app/helpers/hyrax/compound_fields_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -66,24 +66,28 @@ def compound_schema_for(presenter)

##
# Options for a `controlled` sub-property's `<select>`: an inline `values:`
# list when present, otherwise the named QA authority. A stored value not
# among the options is appended so it still renders (`include_current_value`).
# list when present, otherwise the named QA authority. Any stored value not
# among the options is appended so it still renders. +current_value+ may be
# an array (a `multiple: true` member), in which case every stored value is
# ensured.
#
# @return [Array<Array(String, String)>] `[[label, id], ...]`
def compound_subproperty_options(spec, current_value = nil)
options = spec[:values].presence || authority_options(spec[:authority])
ensure_current_value(options, current_value)
Array(current_value).reject(&:blank?).reduce(options) { |opts, value| ensure_current_value(opts, value) }
end

##
# @return [Boolean] whether +current_value+ is present but not among the
# @return [Boolean] whether any present +current_value+ is not among the
# sub-property's offered options — i.e. a forced/stale value. The select
# gets the +force-select+ class in that case, matching the ordinary
# controlled-field convention.
# controlled-field convention. Handles an array value (a `multiple: true`
# member): forced when any selected value is off-list.
def compound_subproperty_forced?(spec, current_value = nil)
return false if current_value.blank?
values = Array(current_value).reject(&:blank?)
return false if values.empty?
base = spec[:values].presence || authority_options(spec[:authority])
base.none? { |(_label, id)| id.to_s == current_value.to_s }
values.any? { |value| base.none? { |(_label, id)| id.to_s == value.to_s } }
end

##
Expand Down
5 changes: 4 additions & 1 deletion app/indexers/hyrax/indexers/compound_indexer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,10 @@ def index_searchable_subproperties(document, definition, rows, compound_name)
index_keys = solr_index_keys(compound_name, sub_property, spec)
next if index_keys.empty?

values = rows.map { |row| compound_entry_value(row, sub_property) }.reject(&:blank?)
# `flatten` so a member whose stored value is itself an array (e.g. a
# `multiple: true` member echoed back before fan-out) contributes each
# term as its own facet value rather than a nested array.
values = rows.flat_map { |row| compound_entry_value(row, sub_property) }.reject(&:blank?)
next if values.empty?

index_keys.each { |index_key| document[index_key] = values }
Expand Down
10 changes: 10 additions & 0 deletions app/renderers/hyrax/renderers/compound_attribute_renderer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,11 @@ def linked_record_markup(sub_property, value)
# 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
# renderer. Non-URI controlled values (e.g. inline option ids) stay plain.
# An array value (a `multiple: true` member echoed back before fan-out)
# renders each term, comma-separated.
def controlled_markup(sub_property, value)
return safe_join_terms(Array(value).map { |v| controlled_markup(sub_property, v) }) if value.is_a?(::Array)

label = display_value(sub_property, value)
if Hyrax::AuthorityRenderingHelper.linkable_uri?(value)
%(<a href="#{ERB::Util.h(value)}" target="_blank" rel="noopener noreferrer">#{ERB::Util.h(label)}</a>).html_safe
Expand All @@ -115,6 +119,12 @@ def controlled_markup(sub_property, value)
end
end

# Join already-escaped term fragments with a comma separator, preserving
# html_safe-ness.
def safe_join_terms(fragments)
fragments.map(&:to_s).join(', ').html_safe
end

# Link a URL or a resolvable work; render anything else as plain text so
# we never emit a broken link to a non-existent work.
def work_or_url_markup(value)
Expand Down
8 changes: 7 additions & 1 deletion app/services/hyrax/compound_schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,13 @@ 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 }.merge(linked_record_keys(opts))
as: form['as']&.to_s,
# `controlled`-only form opt-ins: `autocomplete` binds a select2
# typeahead over the options; `multiple` allows several values, each
# fanned out into its own compound entry at submit time (see
# Hyrax::CompoundFieldBehavior). Both default false.
autocomplete: truthy?(form['autocomplete']),
multiple: truthy?(form['multiple']) }.merge(linked_record_keys(opts))
end

# The `linked_record`-specific declarations: profile-driven lookup-or-create
Expand Down
1 change: 1 addition & 0 deletions app/services/hyrax/compound_subproperty_labeler.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class CompoundSubpropertyLabeler
#
# @return [String] the term to display
def self.label_for(spec, value)
return Array(value).map { |v| label_for(spec, v) }.join(', ') if value.is_a?(::Array)
return value.to_s if spec.nil? || spec[:type].to_s != 'controlled' || value.blank?

if spec[:values].present?
Expand Down
19 changes: 17 additions & 2 deletions app/views/hyrax/compounds/_compound_row.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,27 @@
<% end %>
<% case spec[:type] %>
<% when 'controlled' %>
<%# A controlled member is a single-select by default. `multiple:
true` makes it a multi-select whose values submit as an array
(`[]` name suffix) and fan out into one entry per value
server-side. `autocomplete: true` tags the select so
compound_metadata.js binds a select2 typeahead; the full option
list is already in the DOM, so filtering is client-side. %>
<% multiple = spec[:multiple] %>
<% control_name = multiple ? "#{input_name}[]" : input_name %>
<% select_classes = ['form-control', 'form-control-sm'] %>
<% select_classes << 'force-select' if compound_subproperty_forced?(spec, value) %>
<%= select_tag input_name,
<% control_data = {} %>
<% if spec[:autocomplete] %>
<% control_data['hyrax-compound-controlled'] = true %>
<% control_data['placeholder'] = t('hyrax.compound_fields.search', default: 'Search') %>
<% end %>
<%= select_tag control_name,
options_for_select(compound_subproperty_options(spec, value), value),
id: input_id,
include_blank: true,
include_blank: !multiple,
multiple: multiple,
data: control_data,
class: select_classes.join(' ') %>
<% when 'url' %>
<%= url_field_tag input_name, value,
Expand Down
1 change: 1 addition & 0 deletions config/locales/hyrax.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -930,6 +930,7 @@ en:
default_description: A User Collection can be created by any user to organize their works.
compound_fields:
add_row: Add %{label}
search: Search
remove_row: Remove
remove_row_with_label: Remove %{label}
errors:
Expand Down
32 changes: 31 additions & 1 deletion documentation/compound_fields.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ textarea). Supported `type:` values:
| `url` | url input → auto-linked on show | The stored value is rendered as a clickable `<a href>` 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. |
| `linked_record` | select2 typeahead → linked on show | A reference to a row in a database table (a person, organization, funder, place, …). The stored value is the row id; the picker searches that table and, optionally, lets a cataloger add a new record inline. On show, the id links to the record using the resolved label. The table and its procs are registered by the host application — see [`linked_record` sub-properties](#linked_record-sub-properties) below. |
| `controlled` | `<select>` 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. |
| `controlled` | `<select>` dropdown (optionally a select2 typeahead / multi-select) | 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. Two per-member `form:` opt-ins refine the input — see [Typeahead and multi-select](#typeahead-and-multi-select-for-controlled-members) below. |

A `controlled` sub-property sources its options one of two ways:

Expand Down Expand Up @@ -170,6 +170,36 @@ both are given, `values:` wins. Authority options are read through
`Hyrax::TolerantSelectService`, so an authority file that omits the `active:`
key behaves the same as it does for ordinary fields (terms default to active).

#### Typeahead and multi-select for `controlled` members

A `controlled` member is a single-select plain `<select>` by default. Two
`form:` opt-ins, each defaulting off, refine it:

```yaml
participant_role:
type: controlled
available_on: { properties: [participants] }
authority: participant_role
form:
autocomplete: true # bind a select2 typeahead over the options
multiple: true # allow several values in one row
```

- **`autocomplete: true`** binds a select2 typeahead to the select so the
cataloger can filter the options by typing. The full option list is rendered
into the DOM, so filtering is client-side — no extra request. It works on both
single- and multi-select members.
- **`multiple: true`** renders a multi-select (the values submit as an array).
At submit time each selected value **fans out into its own compound entry**,
sharing the row's other members — so a participant picked with three roles is
stored as three entries of one role each, not one entry holding an array. This
keeps the stored shape uniform (every member value is a scalar) and lets each
term index and facet independently. A row whose only multi-select member is
left empty is still stored once (with that member nil).

Both are independent: a member may be a typeahead single-select, a plain
multi-select, both, or neither.

A `work_or_url` sub-property's work search is broader than the stock "my works"
picker: `Hyrax::CompoundWorkPickerBuilder` matches a typed term against any
indexed query field **or** a partial/prefix title (so "jour" matches "Journal
Expand Down
Loading
Loading