diff --git a/.dassie/config/initializers/bulkrax.rb b/.dassie/config/initializers/bulkrax.rb index a9d18c6b97..5e33064bc5 100644 --- a/.dassie/config/initializers/bulkrax.rb +++ b/.dassie/config/initializers/bulkrax.rb @@ -11,7 +11,7 @@ # config.default_work_type = "MyWork" # Factory Class to use when generating and saving objects - config.object_factory = Bulkrax::ObjectFactory + config.object_factory = Hyrax.config.valkyrie_transition? ? Bulkrax::ValkyrieObjectFactory : Bulkrax::ObjectFactory # Use this for a Postgres-backed Valkyrized Hyrax # config.object_factory = Bulkrax::ValkyrieObjectFactory diff --git a/.dassie/config/metadata_profiles/m3_profile.yaml b/.dassie/config/metadata_profiles/m3_profile.yaml index 758c7e3320..9efa2e09f3 100644 --- a/.dassie/config/metadata_profiles/m3_profile.yaml +++ b/.dassie/config/metadata_profiles/m3_profile.yaml @@ -7,13 +7,11 @@ profile: type: Initial Profile version: 1 classes: - Hyrax::Work: - display_label: Work GenericWorkResource: display_label: Work Monograph: display_label: Work - AdministrativeSetResource: + AdminSetResource: display_label: AdministrativeSet CollectionResource: display_label: PcdmCollection @@ -39,10 +37,9 @@ properties: title: available_on: class: - - AdministrativeSetResource + - AdminSetResource - Hyrax::FileSet - CollectionResource - - Hyrax::Work - Monograph - GenericWorkResource cardinality: @@ -77,10 +74,9 @@ properties: date_modified: available_on: class: - - AdministrativeSetResource + - AdminSetResource - Hyrax::FileSet - CollectionResource - - Hyrax::Work - Monograph - GenericWorkResource cardinality: @@ -105,10 +101,9 @@ properties: date_uploaded: available_on: class: - - AdministrativeSetResource + - AdminSetResource - Hyrax::FileSet - CollectionResource - - Hyrax::Work - Monograph - GenericWorkResource cardinality: @@ -124,10 +119,9 @@ properties: depositor: available_on: class: - - AdministrativeSetResource + - AdminSetResource - Hyrax::FileSet - CollectionResource - - Hyrax::Work - Monograph - GenericWorkResource cardinality: @@ -143,6 +137,7 @@ properties: index_documentation: searchable indexing: - depositor_tesim + - depositor_ssim property_uri: http://id.loc.gov/vocabulary/relators/dpt range: http://www.w3.org/2001/XMLSchema#string sample_values: @@ -152,7 +147,6 @@ properties: class: - Hyrax::FileSet - CollectionResource - - Hyrax::Work - Monograph - GenericWorkResource cardinality: @@ -183,7 +177,7 @@ properties: name: creator available_on: class: - - AdministrativeSetResource + - AdminSetResource cardinality: minimum: 0 data_type: array @@ -209,10 +203,9 @@ properties: license: available_on: class: - - AdministrativeSetResource + - AdminSetResource - Hyrax::FileSet - CollectionResource - - Hyrax::Work - Monograph - GenericWorkResource cardinality: @@ -240,10 +233,9 @@ properties: abstract: available_on: class: - - AdministrativeSetResource + - AdminSetResource - Hyrax::FileSet - CollectionResource - - Hyrax::Work - Monograph - GenericWorkResource cardinality: @@ -270,9 +262,8 @@ properties: access_right: available_on: class: - - AdministrativeSetResource + - AdminSetResource - CollectionResource - - Hyrax::Work - Monograph - GenericWorkResource cardinality: @@ -299,9 +290,8 @@ properties: alternative_title: available_on: class: - - AdministrativeSetResource + - AdminSetResource - CollectionResource - - Hyrax::Work - Monograph - GenericWorkResource cardinality: @@ -356,9 +346,8 @@ properties: arkivo_checksum: available_on: class: - - AdministrativeSetResource + - AdminSetResource - CollectionResource - - Hyrax::Work - Monograph - GenericWorkResource cardinality: @@ -380,10 +369,9 @@ properties: based_near: available_on: class: - - AdministrativeSetResource + - AdminSetResource - Hyrax::FileSet - CollectionResource - - Hyrax::Work - Monograph - GenericWorkResource cardinality: @@ -411,9 +399,8 @@ properties: bibliographic_citation: available_on: class: - - AdministrativeSetResource + - AdminSetResource - CollectionResource - - Hyrax::Work - Monograph - GenericWorkResource cardinality: @@ -440,10 +427,9 @@ properties: contributor: available_on: class: - - AdministrativeSetResource + - AdminSetResource - Hyrax::FileSet - CollectionResource - - Hyrax::Work - Monograph - GenericWorkResource cardinality: @@ -471,10 +457,9 @@ properties: date_created: available_on: class: - - AdministrativeSetResource + - AdminSetResource - Hyrax::FileSet - CollectionResource - - Hyrax::Work - Monograph - GenericWorkResource cardinality: @@ -503,10 +488,9 @@ properties: description: available_on: class: - - AdministrativeSetResource + - AdminSetResource - Hyrax::FileSet - CollectionResource - - Hyrax::Work - Monograph - GenericWorkResource cardinality: @@ -531,10 +515,9 @@ properties: identifier: available_on: class: - - AdministrativeSetResource + - AdminSetResource - Hyrax::FileSet - CollectionResource - - Hyrax::Work - Monograph - GenericWorkResource cardinality: @@ -563,9 +546,8 @@ properties: import_url: available_on: class: - - AdministrativeSetResource + - AdminSetResource - CollectionResource - - Hyrax::Work - Monograph - GenericWorkResource cardinality: @@ -585,10 +567,9 @@ properties: keyword: available_on: class: - - AdministrativeSetResource + - AdminSetResource - Hyrax::FileSet - CollectionResource - - Hyrax::Work - Monograph - GenericWorkResource cardinality: @@ -641,10 +622,9 @@ properties: language: available_on: class: - - AdministrativeSetResource + - AdminSetResource - Hyrax::FileSet - CollectionResource - - Hyrax::Work - Monograph - GenericWorkResource cardinality: @@ -673,10 +653,9 @@ properties: publisher: available_on: class: - - AdministrativeSetResource + - AdminSetResource - Hyrax::FileSet - CollectionResource - - Hyrax::Work - Monograph - GenericWorkResource cardinality: @@ -704,10 +683,9 @@ properties: related_url: available_on: class: - - AdministrativeSetResource + - AdminSetResource - Hyrax::FileSet - CollectionResource - - Hyrax::Work - Monograph - GenericWorkResource cardinality: @@ -735,10 +713,9 @@ properties: relative_path: available_on: class: - - AdministrativeSetResource + - AdminSetResource - Hyrax::FileSet - CollectionResource - - Hyrax::Work - Monograph - GenericWorkResource cardinality: @@ -758,10 +735,9 @@ properties: resource_type: available_on: class: - - AdministrativeSetResource + - AdminSetResource - Hyrax::FileSet - CollectionResource - - Hyrax::Work - Monograph - GenericWorkResource cardinality: @@ -790,10 +766,9 @@ properties: rights_notes: available_on: class: - - AdministrativeSetResource + - AdminSetResource - Hyrax::FileSet - CollectionResource - - Hyrax::Work - Monograph - GenericWorkResource cardinality: @@ -820,10 +795,9 @@ properties: rights_statement: available_on: class: - - AdministrativeSetResource + - AdminSetResource - Hyrax::FileSet - CollectionResource - - Hyrax::Work - Monograph - GenericWorkResource cardinality: @@ -851,10 +825,9 @@ properties: source: available_on: class: - - AdministrativeSetResource + - AdminSetResource - Hyrax::FileSet - CollectionResource - - Hyrax::Work - Monograph - GenericWorkResource cardinality: @@ -882,10 +855,9 @@ properties: subject: available_on: class: - - AdministrativeSetResource + - AdminSetResource - Hyrax::FileSet - CollectionResource - - Hyrax::Work - Monograph - GenericWorkResource cardinality: @@ -914,7 +886,6 @@ properties: dimensions: available_on: class: - - Hyrax::Work - Monograph - GenericWorkResource context: @@ -955,4 +926,4 @@ properties: display: true primary: false range: http://www.w3.org/2001/XMLSchema#string - property_uri: http://vocabulary.samvera.org/ns#transcriptIds \ No newline at end of file + property_uri: http://vocabulary.samvera.org/ns#transcriptIds diff --git a/.koppie/config/metadata_profiles/m3_profile.yaml b/.koppie/config/metadata_profiles/m3_profile.yaml index 083f7873d0..4159201425 100644 --- a/.koppie/config/metadata_profiles/m3_profile.yaml +++ b/.koppie/config/metadata_profiles/m3_profile.yaml @@ -929,8 +929,12 @@ properties: range: http://www.w3.org/2001/XMLSchema#string property_uri: http://vocabulary.samvera.org/ns#transcriptIds # Sample compound (hierarchical) metadata, demonstrating the Hyrax compound - # foundation in flexible mode. See documentation/forms/compound_fields.md. - agents: + # foundation in flexible mode. A compound is a `type: hash, multiple: true` + # parent; its members are declared as separate properties pointing back with + # `subproperty_of: `, each carrying its own `available_on`, indexing, + # and form placement. `subproperty_of` entries are not standalone resource + # attributes. See documentation/forms/compound_fields.md. + participants: available_on: class: - GenericWork @@ -939,26 +943,42 @@ properties: type: hash range: http://www.w3.org/2001/XMLSchema#string display_label: - default: Agents + default: Participants form: - primary: false + primary: true property_uri: http://id.loc.gov/vocabulary/relators/asn - subfields: - title: { type: string } - agent_name: - type: string - required: true - indexing: [agent_name_sim, agent_name_tesim] - agent_role: - type: controlled - required: true - values: [Author, Editor, Contributor, Creator, Data Collector, Funder, Publisher, Other] - indexing: [agent_role_sim, agent_role_tesim] + # Group metadata (label) for subproperties declaring `group: `. groups: - - { label: ~, cols: 4, fields: [title, agent_name, agent_role] } + identity: + label: Identity view: render_as: compound html_dl: true + # Subproperties omit `available_on`/`range`/`display_label`: they inherit the + # parent compound's class scope and are folded into it by the schema loader. + participant_title: + type: string + subproperty_of: participants + group: identity + form: + cols: 4 + participant_name: + type: string + subproperty_of: participants + group: identity + required: true + indexing: [participant_name_sim, participant_name_tesim] + form: + cols: 4 + participant_role: + type: controlled + subproperty_of: participants + group: identity + required: true + values: [Author, Editor, Contributor, Creator, Data Collector, Funder, Publisher, Other] + indexing: [participant_role_sim, participant_role_tesim] + form: + cols: 4 identifiers: available_on: class: @@ -972,19 +992,24 @@ properties: form: primary: false property_uri: http://purl.org/dc/terms/identifier - subfields: - identifier: - type: string - required: true - indexing: [identifier_value_sim, identifier_value_tesim] - identifier_type: - type: controlled - required: true - values: [DOI, Handle, ISBN, ISSN, URL, URN, ARK, Other] - indexing: [identifier_type_sim] view: render_as: compound html_dl: true + identifier_value: + type: string + subproperty_of: identifiers + required: true + indexing: [identifier_value_sim, identifier_value_tesim] + form: + cols: 6 + identifier_type: + type: controlled + subproperty_of: identifiers + required: true + values: [DOI, Handle, ISBN, ISSN, URL, URN, ARK, Other] + indexing: [identifier_type_sim] + form: + cols: 6 compound_rights: available_on: class: @@ -998,21 +1023,29 @@ properties: form: primary: false property_uri: http://purl.org/dc/terms/rights - subfields: - rights_statement: - type: controlled - authority: rights_statements - indexing: [compound_rights_statement_sim, compound_rights_statement_tesim] - license: - type: controlled - authority: licenses - indexing: [compound_rights_license_sim, compound_rights_license_tesim] - rights_notes: - type: string - indexing: [compound_rights_notes_tesim] view: render_as: compound html_dl: true + compound_rights_statement: + type: controlled + subproperty_of: compound_rights + authority: rights_statements + indexing: [compound_rights_statement_sim, compound_rights_statement_tesim] + form: + cols: 6 + compound_rights_license: + type: controlled + subproperty_of: compound_rights + authority: licenses + indexing: [compound_rights_license_sim, compound_rights_license_tesim] + form: + cols: 6 + compound_rights_notes: + type: string + subproperty_of: compound_rights + indexing: [compound_rights_notes_tesim] + form: + cols: 12 relationships: available_on: class: @@ -1024,19 +1057,24 @@ properties: display_label: default: Relationships form: - primary: false + primary: true property_uri: http://purl.org/dc/terms/relation - subfields: - related_item: - type: work_or_url - required: true - indexing: [relationships_item_ssim] - relationship_type: - type: controlled - required: true - values: [References, Is Referenced By, Is Part Of, Has Part, Is Version Of, Replaces, Requires, Other] - indexing: [relationships_type_sim] view: render_as: compound html_dl: true - display: card \ No newline at end of file + display: card + relationship_item: + type: work_or_url + subproperty_of: relationships + required: true + indexing: [relationships_item_ssim] + form: + cols: 6 + relationship_type: + type: controlled + subproperty_of: relationships + required: true + values: [References, Is Referenced By, Is Part Of, Has Part, Is Version Of, Replaces, Requires, Other] + indexing: [relationships_type_sim] + form: + cols: 6 \ No newline at end of file diff --git a/app/assets/javascripts/hyrax/compound_metadata.js b/app/assets/javascripts/hyrax/compound_metadata.js index b5a7099de2..3f09bb7ed6 100644 --- a/app/assets/javascripts/hyrax/compound_metadata.js +++ b/app/assets/javascripts/hyrax/compound_metadata.js @@ -7,7 +7,7 @@ if (document.hyraxCompoundsBound) return; document.hyraxCompoundsBound = true; - // Bind select2 to a `work_or_url` sub-field. The v3 API (Hyrax bundles + // Bind select2 to a `work_or_url` sub-property. The v3 API (Hyrax bundles // select2-rails 3.x) binds to a hidden input; createSearchChoice lets a // typed external URL be selected as-is. function bindWorkOrUrlInputs(root) { diff --git a/app/assets/stylesheets/hyrax/_compound_metadata.scss b/app/assets/stylesheets/hyrax/_compound_metadata.scss index 15e54343b7..17e8c6fcee 100644 --- a/app/assets/stylesheets/hyrax/_compound_metadata.scss +++ b/app/assets/stylesheets/hyrax/_compound_metadata.scss @@ -12,17 +12,17 @@ padding: 0 !important; max-width: 100%; - // Force block flow on the entries/sub-fields; the label/value spans stay - // inline so each sub-field reads as "Label: value" on one wrapping line. + // 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. .hyrax-compound-entry, - .hyrax-compound-subfield { + .hyrax-compound-subproperty { display: block !important; } .hyrax-compound-entry, - .hyrax-compound-subfield, - .hyrax-compound-subfield-label, - .hyrax-compound-subfield-value { + .hyrax-compound-subproperty, + .hyrax-compound-subproperty-label, + .hyrax-compound-subproperty-value { border: 0 !important; padding: 0 !important; margin: 0; @@ -36,14 +36,14 @@ border-top: 1px solid $gray-200 !important; } - .hyrax-compound-subfield { + .hyrax-compound-subproperty { display: block; line-height: 1.4; overflow-wrap: break-word; word-break: break-word; } - .hyrax-compound-subfield-label { + .hyrax-compound-subproperty-label { font-weight: 600; margin-right: 0.25rem; } diff --git a/app/authorities/qa/authorities/compound_works.rb b/app/authorities/qa/authorities/compound_works.rb index 66e70cba84..3f84882291 100644 --- a/app/authorities/qa/authorities/compound_works.rb +++ b/app/authorities/qa/authorities/compound_works.rb @@ -2,7 +2,7 @@ module Qa::Authorities ## - # Autocomplete authority for the compound `work_or_url` sub-field's work + # Autocomplete authority for the compound `work_or_url` sub-property's work # picker, mounted at `/authorities/search/compound_works`. Returns readable # works matched by {Hyrax::CompoundWorkPickerBuilder}. class CompoundWorks < Qa::Authorities::Base diff --git a/app/forms/concerns/hyrax/compound_field_behavior.rb b/app/forms/concerns/hyrax/compound_field_behavior.rb index f5ca4bf3c0..f018b9127b 100644 --- a/app/forms/concerns/hyrax/compound_field_behavior.rb +++ b/app/forms/concerns/hyrax/compound_field_behavior.rb @@ -8,6 +8,8 @@ module Hyrax # model and wires each one's virtual `_attributes` property and # populator from the schema, in both flex modes. module CompoundFieldBehavior + include Hyrax::CompoundRowPlumbing + # Register the virtual `_attributes` populator properties on the # singleton class for every compound on the resource. Must run before Reform # builds the deserialization schema; the flexible-mode init path calls this, @@ -30,7 +32,7 @@ def register_compound_fields!(resource) # Strip each compound's renamed bare key so the `_attributes` # populator is the single write entry point (the Field Behavior contract; - # see documentation/forms/field_behaviors.md). + # see documentation/field_behaviors.md). def deserialize!(params) result = super return result unless result.respond_to?(:delete) @@ -53,32 +55,27 @@ def compound_field_names end # One populator serves every compound (Reform passes the property name as - # `as:`). Builds the replacement array of plain hashes — declared sub-field - # keys only, dropping `_destroy` and all-blank rows. + # `as:`). Builds the replacement array of plain hashes — declared + # sub-property keys only, dropping `_destroy` and all-blank rows. def compound_attributes_populator(fragment:, as:, **_options) name = as.to_s.delete_suffix('_attributes') return unless respond_to?(name) - allowed = Hyrax::CompoundSchema.for(model).subfield_keys(name) + allowed = Hyrax::CompoundSchema.for(model).subproperty_keys(name) public_send(:"#{name}=", build_compound_rows(fragment, allowed)) end def build_compound_rows(fragment, allowed_keys) - compound_fragment_pairs(fragment) + fragment_pairs(fragment) .sort_by { |key, _row| key.to_i } .map { |_key, row| compound_row_from(row, allowed_keys) } .compact end - def compound_fragment_pairs(fragment) - return {} if fragment.nil? - fragment.respond_to?(:to_unsafe_h) ? fragment.to_unsafe_h : fragment.to_h - end - - # Returns nil for a row marked for destruction or whose declared sub-fields + # 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) - row = row.respond_to?(:to_unsafe_h) ? row.to_unsafe_h : row.to_h + row = row_hash(row) return nil if %w[true 1].include?(row['_destroy'].to_s) entry = allowed_keys.each_with_object({}) do |key, memo| diff --git a/app/forms/concerns/hyrax/compound_row_plumbing.rb b/app/forms/concerns/hyrax/compound_row_plumbing.rb new file mode 100644 index 0000000000..3be52ab776 --- /dev/null +++ b/app/forms/concerns/hyrax/compound_row_plumbing.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Hyrax + # Shared coercion of a nested-attributes fragment into plain Ruby hashes, + # used by both {Hyrax::CompoundFieldBehavior} and + # {Hyrax::RedirectsFieldBehavior}. It only normalizes shape (unwrapping + # `ActionController::Parameters` via `to_unsafe_h`); the row-drop rules + # (`_destroy`, blank paths, all-blank rows) and any value normalization stay + # in each populator. + module CompoundRowPlumbing + private + + # The submitted `_attributes` payload as a `{ index => row }` hash. + def fragment_pairs(fragment) + return {} if fragment.nil? + fragment.respond_to?(:to_unsafe_h) ? fragment.to_unsafe_h : fragment.to_h + end + + # A single row as a plain hash. + def row_hash(row) + row.respond_to?(:to_unsafe_h) ? row.to_unsafe_h : row.to_h + end + end +end diff --git a/app/forms/concerns/hyrax/flexible_form_behavior.rb b/app/forms/concerns/hyrax/flexible_form_behavior.rb index fa59896c4d..f0874b1a23 100644 --- a/app/forms/concerns/hyrax/flexible_form_behavior.rb +++ b/app/forms/concerns/hyrax/flexible_form_behavior.rb @@ -13,7 +13,7 @@ def validate_flexible_required_fields required_fields = singleton_class.schema_definitions.select { |_, opts| opts[:required] }.keys required_fields.each do |field| - # Compound fields carry their own requiredness rules (per-row sub-field + # Compound fields carry their own requiredness rules (per-row sub-property # checks); Hyrax::CompoundEntryValidator owns them, so skip the generic # blank check to avoid a duplicate "can't be blank" error. next if compound_field?(field) @@ -29,9 +29,9 @@ def schema private - # Whether the field is a compound (declares `subfields:`), looked up from + # Whether the field is a compound (declares `subproperties:`), looked up from # the resource's compound schema — the flex form definitions don't carry - # `subfields`. Memoized per form instance. + # `subproperties`. Memoized per form instance. def compound_field?(field) return false unless Hyrax.config.respond_to?(:compound_metadata_enabled?) && Hyrax.config.compound_metadata_enabled? @compound_field_names ||= Hyrax::CompoundSchema.for(model).compound_names diff --git a/app/forms/concerns/hyrax/redirects_field_behavior.rb b/app/forms/concerns/hyrax/redirects_field_behavior.rb index dbe30d17f9..b55565c85d 100644 --- a/app/forms/concerns/hyrax/redirects_field_behavior.rb +++ b/app/forms/concerns/hyrax/redirects_field_behavior.rb @@ -28,6 +28,8 @@ module Hyrax # `Flipflop.redirects?` directly is unsafe when the config is off # because the feature isn't registered in that case. module RedirectsFieldBehavior + include Hyrax::CompoundRowPlumbing + def self.included(descendant) return unless Hyrax.config.redirects_enabled? # Declare the radio-group scalar before redirects_attributes so Reform @@ -66,19 +68,14 @@ def deserialize!(params) def redirects_attributes_populator(fragment:, **_options) return unless respond_to?(:redirects) return unless Hyrax.config.redirects_active? - pairs = redirects_fragment_pairs(fragment) + pairs = fragment_pairs(fragment) self.redirects = pairs.sort_by { |k, _row| k.to_i } .map { |k, row| redirects_entry_from(k, row) } .compact end - def redirects_fragment_pairs(fragment) - return {} if fragment.nil? - fragment.respond_to?(:to_unsafe_h) ? fragment.to_unsafe_h : fragment.to_h - end - def redirects_entry_from(key, row) - row = row.respond_to?(:to_unsafe_h) ? row.to_unsafe_h : row + row = row_hash(row) return nil if row['_destroy'].to_s == 'true' || row['path'].to_s.strip.empty? { 'path' => Hyrax::RedirectPathNormalizer.call(row['path']), 'is_display_url' => redirects_is_display_url_flag_for(key, row) } diff --git a/app/forms/hyrax/forms/resource_form.rb b/app/forms/hyrax/forms/resource_form.rb index c2cbab8578..39fa1aa44b 100644 --- a/app/forms/hyrax/forms/resource_form.rb +++ b/app/forms/hyrax/forms/resource_form.rb @@ -64,7 +64,7 @@ class ResourceForm < Hyrax::ChangeSet # rubocop:disable Metrics/ClassLength end end - # Required-compound / required-sub-field validation. Wired through a + # Required-compound / required-sub-property validation. Wired through a # `validation { ... }` block (not a bare `validates_with`) because these # are Reform/Disposable forms — a bare `validates_with` does not hook into # Reform's `validate`, so it would never run. Record-level (no @@ -268,16 +268,41 @@ def compound_terms [] end + ## + # @return [Array] compounds whose `form: { primary: true }`, shown + # in the primary form section alongside the primary scalar terms. + def primary_compound_terms + compound_terms.select { |term| compound_primary?(term) } + end + + ## + # @return [Array] compounds that are not primary (the default), + # shown in the "Additional fields" section. + def secondary_compound_terms + compound_terms.reject { |term| compound_primary?(term) } + end + ## # @return [Boolean] whether there are terms to display 'below-the-fold' + # (secondary scalar terms or non-primary compounds) def display_additional_fields? - secondary_terms.any? + secondary_terms.any? || secondary_compound_terms.any? end delegate :flexible?, to: :model private + # Whether a compound declares `form: { primary: true }`. Defaults to false + # (compounds render in "Additional fields" unless explicitly primary), + # mirroring how secondary scalar terms are derived. Read from the compound + # definition, which carries the flag in both flex modes. + def compound_primary?(term) + Hyrax::CompoundSchema.for(model).primary?(term) + rescue StandardError + false + end + def _form_field_definitions if model.flexible? singleton_class.schema_definitions diff --git a/app/helpers/hyrax/collections_helper.rb b/app/helpers/hyrax/collections_helper.rb index 6fb60d1c79..ba90fdb738 100644 --- a/app/helpers/hyrax/collections_helper.rb +++ b/app/helpers/hyrax/collections_helper.rb @@ -45,11 +45,11 @@ def collection_metadata_value(collection, field) # Compounds render through the shared CompoundAttributeRenderer, resolving # the definition from the presenter's document (correct in flexible mode). # Collection scalar values render as bare spans (no `ul.tabular`), so the - # entries render flush to match. See documentation/forms/compound_fields.md. + # entries render flush to match. See documentation/compound_fields.md. if collection.respond_to?(:compound_term?) && collection.compound_term?(field) definition = compound_schema_for(collection).definition_for(field) return Hyrax::Renderers::CompoundAttributeRenderer - .new(field, collection[field], subfields: definition&.fetch(:subfields, nil)) + .new(field, collection[field], subproperties: definition&.fetch(:subproperties, nil)) .render_value end diff --git a/app/helpers/hyrax/compound_fields_helper.rb b/app/helpers/hyrax/compound_fields_helper.rb index 6e8c5f0635..effc34590e 100644 --- a/app/helpers/hyrax/compound_fields_helper.rb +++ b/app/helpers/hyrax/compound_fields_helper.rb @@ -2,10 +2,10 @@ module Hyrax # View helpers for rendering compound (hierarchical) metadata fields on forms - # and show pages. See documentation/forms/compound_fields.md. + # and show pages. See documentation/compound_fields.md. module CompoundFieldsHelper ## - # Renders one compound section (a repeatable stack of sub-field rows) for the + # Renders one compound section (a repeatable stack of sub-property rows) for the # given attribute via the `hyrax/compounds/*` partials. # # @return [String, nil] rendered HTML, or nil when the attribute is not a @@ -65,29 +65,29 @@ def compound_schema_for(presenter) end ## - # Options for a `controlled` sub-field's ``: 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`). # # @return [Array] `[[label, id], ...]` - def compound_subfield_options(spec, current_value = nil) + def compound_subproperty_options(spec, current_value = nil) options = spec[:values].presence || authority_options(spec[:authority]) ensure_current_value(options, current_value) end ## # @return [Boolean] whether +current_value+ is present but not among the - # sub-field's offered options — i.e. a forced/stale value. The select + # 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. - def compound_subfield_forced?(spec, current_value = nil) + def compound_subproperty_forced?(spec, current_value = nil) return false if current_value.blank? base = spec[:values].presence || authority_options(spec[:authority]) base.none? { |(_label, id)| id.to_s == current_value.to_s } end ## - # The pre-selected `[label, value]` option for a `work_or_url` sub-field's + # The pre-selected `[label, value]` option for a `work_or_url` sub-property's # select2, or nil when empty. An internal work id resolves to its title; an # external URL is shown as-is. # @@ -121,9 +121,9 @@ def compound_card_label(presenter, field) compound_field_label(field, display_label: compound_schema_for(presenter).definition_for(field)&.dig(:display_label)) end - def compound_subfield_label(compound_name, sub_field) - t("hyrax.compound_fields.#{compound_name}.#{sub_field}", - default: sub_field.to_s.humanize) + def compound_subproperty_label(compound_name, sub_property) + t("hyrax.compound_fields.#{compound_name}.#{sub_property}", + default: sub_property.to_s.humanize) end private @@ -135,7 +135,7 @@ def authority_options(authority_name) return [] if authority_name.blank? Hyrax::TolerantSelectService.new(authority_name).select_active_options rescue StandardError => e - Hyrax.logger.debug("compound_subfield_options: #{authority_name}: #{e.message}") + Hyrax.logger.debug("compound_subproperty_options: #{authority_name}: #{e.message}") [] end diff --git a/app/indexers/hyrax/indexers/compound_indexer.rb b/app/indexers/hyrax/indexers/compound_indexer.rb index 3983166396..257f42ae82 100644 --- a/app/indexers/hyrax/indexers/compound_indexer.rb +++ b/app/indexers/hyrax/indexers/compound_indexer.rb @@ -3,11 +3,11 @@ module Hyrax module Indexers ## - # Indexer mixin that projects compound metadata sub-fields into Solr. For + # Indexer mixin that projects compound metadata sub-properties into Solr. For # every compound on the resource (see {Hyrax::CompoundSchema}), it writes - # each sub-field's declared `index_keys:`/`indexing:` Solr fields and stores - # the displayable rows as a `_json_ss` blob the show page renders - # from. See documentation/forms/compound_fields.md. + # each sub-property's declared `index_keys:`/`indexing:` Solr fields and + # stores the displayable rows as a `_json_ss` blob the show page + # renders from. See documentation/compound_fields.md. # # @example # class WorkIndexer < Hyrax::Indexers::PcdmObjectIndexer @@ -31,15 +31,15 @@ def compound_schema def index_compound(document, compound_name, definition) rows = Array(resource.public_send(compound_name)) - index_searchable_subfields(document, definition, rows) + index_searchable_subproperties(document, definition, rows) index_display_blob(document, compound_name, definition, rows) end - def index_searchable_subfields(document, definition, rows) - definition[:subfields].each do |sub_field, spec| + def index_searchable_subproperties(document, definition, rows) + definition[:subproperties].each do |sub_property, spec| next if spec[:index_keys].blank? - values = rows.map { |row| compound_entry_value(row, sub_field) }.reject(&:blank?) + values = rows.map { |row| compound_entry_value(row, sub_property) }.reject(&:blank?) next if values.empty? spec[:index_keys].each { |index_key| document[index_key] = values } @@ -47,7 +47,7 @@ def index_searchable_subfields(document, definition, rows) end def index_display_blob(document, compound_name, definition, rows) - display_keys = definition[:subfields].select { |_k, spec| spec[:display] }.keys + display_keys = definition[:subproperties].select { |_k, spec| spec[:display] }.keys normalized = rows.map { |row| display_entry(row, display_keys) }.reject(&:empty?) document["#{compound_name}_json_ss"] = normalized.to_json unless normalized.empty? end @@ -60,9 +60,9 @@ def display_entry(row, display_keys) end end - def compound_entry_value(row, sub_field) + def compound_entry_value(row, sub_property) return nil unless row.respond_to?(:[]) - row[sub_field] || row[sub_field.to_sym] + row[sub_property] || row[sub_property.to_sym] end end end diff --git a/app/indexers/hyrax/indexers/pcdm_collection_indexer.rb b/app/indexers/hyrax/indexers/pcdm_collection_indexer.rb index d356fdb934..77c58a3b69 100644 --- a/app/indexers/hyrax/indexers/pcdm_collection_indexer.rb +++ b/app/indexers/hyrax/indexers/pcdm_collection_indexer.rb @@ -11,8 +11,8 @@ class PcdmCollectionIndexer < Hyrax::Indexers::ResourceIndexer include Hyrax::ThumbnailIndexer include Hyrax::Indexer(:core_metadata) if Hyrax.config.collection_include_metadata? include Hyrax::Indexers::RedirectsIndexer if Hyrax.config.redirects_enabled? - # Flatten compound (hierarchical) metadata sub-fields into Solr. No-op for - # collections without compounds. See documentation/forms/compound_fields.md. + # Flatten compound (hierarchical) metadata sub-properties into Solr. No-op for + # collections without compounds. See documentation/compound_fields.md. include Hyrax::Indexers::CompoundIndexer if Hyrax.config.compound_metadata_enabled? check_if_flexible(Hyrax::PcdmCollection) diff --git a/app/indexers/hyrax/indexers/pcdm_object_indexer.rb b/app/indexers/hyrax/indexers/pcdm_object_indexer.rb index 58100c64bb..907365367c 100644 --- a/app/indexers/hyrax/indexers/pcdm_object_indexer.rb +++ b/app/indexers/hyrax/indexers/pcdm_object_indexer.rb @@ -11,8 +11,8 @@ class PcdmObjectIndexer < Hyrax::Indexers::ResourceIndexer include Hyrax::ThumbnailIndexer include Hyrax::WorkflowIndexer include Hyrax::Indexers::RedirectsIndexer if Hyrax.config.redirects_enabled? - # Flatten compound (hierarchical) metadata sub-fields into Solr. No-op for - # resources without compounds. See documentation/forms/compound_fields.md. + # Flatten compound (hierarchical) metadata sub-properties into Solr. No-op for + # resources without compounds. See documentation/compound_fields.md. include Hyrax::Indexers::CompoundIndexer if Hyrax.config.compound_metadata_enabled? def to_solr # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength diff --git a/app/models/concerns/hyrax/compound_normalization.rb b/app/models/concerns/hyrax/compound_normalization.rb index 62f7fdf5af..1cec6b78fc 100644 --- a/app/models/concerns/hyrax/compound_normalization.rb +++ b/app/models/concerns/hyrax/compound_normalization.rb @@ -2,7 +2,7 @@ module Hyrax ## - # Defends compound attributes (`type: hash, multiple: true` with `subfields:` — + # Defends compound attributes (`type: hash, multiple: true` with `subproperties:` — # see {Hyrax::CompoundSchema}) against a read-path quirk in Valkyrie's Postgres # orm_converter: `JSONValueMapper` unwraps a single-element array to its first # element and re-symbolizes the Hash's keys, then dry-struct's `Array.of(Hash)` diff --git a/app/models/concerns/hyrax/solr_document/metadata.rb b/app/models/concerns/hyrax/solr_document/metadata.rb index 1e4e63ca85..637f561a61 100644 --- a/app/models/concerns/hyrax/solr_document/metadata.rb +++ b/app/models/concerns/hyrax/solr_document/metadata.rb @@ -12,7 +12,7 @@ def attribute(name, type, field) # Declare a reader for a compound attribute, parsing the rows the # indexer stored in `_json_ss`. See - # documentation/forms/compound_fields.md. + # documentation/compound_fields.md. def compound_attribute(name) define_method name do Solr::CompoundEntries.coerce(self["#{name}_json_ss"]) diff --git a/app/models/concerns/hyrax/valkyrie_lazy_migration.rb b/app/models/concerns/hyrax/valkyrie_lazy_migration.rb index 929dfe9aed..0877979da0 100644 --- a/app/models/concerns/hyrax/valkyrie_lazy_migration.rb +++ b/app/models/concerns/hyrax/valkyrie_lazy_migration.rb @@ -50,7 +50,7 @@ def initialize(klass, *args) # Hyrax::ValkyrieLazyMigration.migrating(self, from: MyWork) # end def self.migrating(klass, from:, name_class: Hyrax::ValkyrieLazyMigration::ResourceName) - Wings::ModelRegistry.register(klass, from) + Wings::ModelRegistry.register(klass, from) if defined?(Wings::ModelRegistry) from.singleton_class.define_method(:migrating_from) { from } from.singleton_class.define_method(:migrating_to) { klass } klass.singleton_class.define_method(:migrating_from) { from } diff --git a/app/models/hyrax/flexible_schema.rb b/app/models/hyrax/flexible_schema.rb index 069d67cb41..af4b5fd7ba 100644 --- a/app/models/hyrax/flexible_schema.rb +++ b/app/models/hyrax/flexible_schema.rb @@ -152,19 +152,41 @@ def class_names profile['classes'].keys.each do |class_name| @class_names[class_name] = {} end - profile['properties'].each do |key, values| + all_properties = profile['properties'] || {} + all_properties.each do |key, values| property_name = values['name'] || key - values['available_on']['class'].each do |property_class| + # A compound subproperty may omit `available_on`; it inherits the class + # scope of its parent compound. (It still appears in each class's attribute + # map so the schema loader can fold it into the parent; the loader excludes + # it from the resource's real attributes.) + classes = property_classes(values, all_properties) + next if classes.blank? + + classes.each do |property_class| # map some m3 items to what Hyrax expects - values = values_map(values) - @class_names[property_class][property_name] = values + mapped = values_map(values) + @class_names[property_class] ||= {} + @class_names[property_class][property_name] = mapped end end @class_names end + # The classes a property is available on: its own `available_on`, or — for a + # compound subproperty that omits it — its parent compound's `available_on`. + def property_classes(values, all_properties) + own = values.dig('available_on', 'class') + return Array(own) if own.present? + + parent = values['subproperty_of'] && all_properties[values['subproperty_of'].to_s] + Array(parent&.dig('available_on', 'class')) + end + def values_map(values) - values['type'] = lookup_type(values['range']) + # Derive the Dry type from the XSD `range` when present; otherwise keep the + # declared `type:` (compound subproperties declare `type:` directly and have + # no `range` of their own). + values['type'] = lookup_type(values['range']) if values['range'].present? values['predicate'] = values['property_uri'] values['index_keys'] = values['indexing'] values['context'] = values.dig('available_on', 'context') @@ -209,6 +231,8 @@ def assign_required_flag(values) def lookup_type(range) case range + when nil, '' + nil when "http://www.w3.org/2001/XMLSchema#dateTime" 'date_time' else diff --git a/app/models/hyrax/pcdm_collection.rb b/app/models/hyrax/pcdm_collection.rb index 52ce21ff91..f412664514 100644 --- a/app/models/hyrax/pcdm_collection.rb +++ b/app/models/hyrax/pcdm_collection.rb @@ -47,7 +47,7 @@ class PcdmCollection < Hyrax::Resource # Sample compound metadata, mirroring Hyrax::Work. In flexible mode the # compounds come from the m3 profile, so only the read-path normalization is - # included here. See documentation/forms/compound_fields.md. + # included here. See documentation/compound_fields.md. if Hyrax.config.compound_metadata_enabled? include Hyrax::Schema(:compound_metadata) unless Hyrax.config.flexible? include Hyrax::CompoundNormalization diff --git a/app/models/hyrax/work.rb b/app/models/hyrax/work.rb index d300fcd722..1c6c9a6ba7 100644 --- a/app/models/hyrax/work.rb +++ b/app/models/hyrax/work.rb @@ -101,7 +101,7 @@ class Work < Hyrax::Resource # Sample compound metadata. In flexible mode the compounds come from the m3 # profile, so only the read-path normalization is included here. See - # documentation/forms/compound_fields.md. + # documentation/compound_fields.md. if Hyrax.config.compound_metadata_enabled? include Hyrax::Schema(:compound_metadata) unless Hyrax.config.flexible? include Hyrax::CompoundNormalization diff --git a/app/presenters/hyrax/collection_presenter.rb b/app/presenters/hyrax/collection_presenter.rb index 61c6a1fb08..dca38309ca 100644 --- a/app/presenters/hyrax/collection_presenter.rb +++ b/app/presenters/hyrax/collection_presenter.rb @@ -69,7 +69,7 @@ def terms_with_values # Inline compound terms for this collection (card compounds render # separately via `render_compound_cards`). Resolved from the backing - # document. See documentation/forms/compound_fields.md. + # document. See documentation/compound_fields.md. def compound_terms return [] unless Hyrax.config.compound_metadata_enabled? compound_schema.inline_compound_names diff --git a/app/presenters/hyrax/presents_attributes.rb b/app/presenters/hyrax/presents_attributes.rb index 54925cb013..18f70b0db3 100644 --- a/app/presenters/hyrax/presents_attributes.rb +++ b/app/presenters/hyrax/presents_attributes.rb @@ -22,7 +22,7 @@ def attribute_to_html(field, options = {}) return end - options = options.merge(subfields: compound_subfields_for(field)) if options[:render_as].to_s == 'compound' + options = options.merge(subproperties: compound_subproperties_for(field)) if options[:render_as].to_s == 'compound' renderer = renderer_for(field, options).new(field, send(field), options) if options[:value_only] && renderer.respond_to?(:render_value) @@ -54,18 +54,18 @@ def microdata_type_to_html private - # Normalized sub-field specs for a compound, so the renderer can translate + # Normalized sub-property specs for a compound, so the renderer can translate # controlled ids to their terms; nil if the resource class can't be # resolved (the renderer then renders raw values). - def compound_subfields_for(field) + def compound_subproperties_for(field) return nil unless respond_to?(:solr_document) && solr_document.respond_to?(:hydra_model) # Resolve from the backing document, not the class: in flexible mode the - # class carries no compounds, so a class lookup would drop the sub-field + # class carries no compounds, so a class lookup would drop the sub-property # specs and the renderer would fall back to raw (unlinked, untranslated) # values. - Hyrax::CompoundSchema.for_solr_document(solr_document).definition_for(field)&.fetch(:subfields, nil) + Hyrax::CompoundSchema.for_solr_document(solr_document).definition_for(field)&.fetch(:subproperties, nil) rescue StandardError => e - Hyrax.logger.debug("compound_subfields_for(#{field}): #{e.message}") + Hyrax.logger.debug("compound_subproperties_for(#{field}): #{e.message}") nil end diff --git a/app/renderers/hyrax/renderers/compound_attribute_renderer.rb b/app/renderers/hyrax/renderers/compound_attribute_renderer.rb index d474ef5ca5..4807632b0a 100644 --- a/app/renderers/hyrax/renderers/compound_attribute_renderer.rb +++ b/app/renderers/hyrax/renderers/compound_attribute_renderer.rb @@ -5,9 +5,9 @@ module Renderers ## # Renders a compound metadata attribute on a show page (selected via # `view: { render_as: compound }`). Each value is one entry — a hash of - # sub-fields produced by the SolrDocument `compound_attribute` reader — and - # renders as a block of its populated sub-fields. Sub-field labels come from - # the `hyrax.compound_fields..` i18n keys. + # 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 def render return '' if blank_values? && !options[:include_empty] @@ -51,33 +51,33 @@ def entry_markup(entry) pairs = entry_to_pairs(entry) return '' if pairs.empty? - items = pairs.map { |sub_field, value| subfield_markup(sub_field, value) }.join + items = pairs.map { |sub_property, value| subproperty_markup(sub_property, value) }.join %(
#{items}
) end - def subfield_markup(sub_field, value) - label_html = ERB::Util.h(sub_field_label(sub_field)) - %(
) + - %(#{label_html}: ) + - %(#{value_markup(sub_field, value)}) + + def subproperty_markup(sub_property, value) + label_html = ERB::Util.h(sub_property_label(sub_property)) + %(
) + + %(#{label_html}: ) + + %(#{value_markup(sub_property, value)}) + %(
) end - # Display markup for one sub-field value, by sub-field type: `url` and + # 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. - def value_markup(sub_field, value) - return ERB::Util.h(display_value(sub_field, value)) if value.blank? + def value_markup(sub_property, value) + return ERB::Util.h(display_value(sub_property, value)) if value.blank? - case subfield_spec(sub_field)&.dig(:type).to_s + case subproperty_spec(sub_property)&.dig(:type).to_s when 'url' auto_link(ERB::Util.h(value.to_s)) when 'work_or_url' work_or_url_markup(value) when 'controlled' - controlled_markup(sub_field, value) + controlled_markup(sub_property, value) else - ERB::Util.h(display_value(sub_field, value)) + ERB::Util.h(display_value(sub_property, value)) end end @@ -85,8 +85,8 @@ def value_markup(sub_field, 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. - def controlled_markup(sub_field, value) - label = display_value(sub_field, value) + def controlled_markup(sub_property, value) + label = display_value(sub_property, value) if Hyrax::AuthorityRenderingHelper.linkable_uri?(value) %(#{ERB::Util.h(label)}).html_safe else @@ -104,19 +104,19 @@ def work_or_url_markup(value) link_to(ERB::Util.h(title), path) end - def display_value(sub_field, value) - Hyrax::CompoundSubfieldLabeler.label_for(subfield_spec(sub_field), value) + def display_value(sub_property, value) + Hyrax::CompoundSubpropertyLabeler.label_for(subproperty_spec(sub_property), value) end - # The normalized sub-field spec, supplied by the caller via - # `options[:subfields]`. - def subfield_spec(sub_field) - specs = options[:subfields] + # The normalized sub-property spec, supplied by the caller via + # `options[:subproperties]`. + def subproperty_spec(sub_property) + specs = options[:subproperties] return nil unless specs.is_a?(Hash) - specs[sub_field] || specs[sub_field.to_sym] + specs[sub_property] || specs[sub_property.to_sym] end - # Populated [sub_field, value] pairs for one entry; blanks dropped. + # Populated [sub_property, value] pairs for one entry; blanks dropped. def entry_to_pairs(entry) return [] unless entry.respond_to?(:each_pair) || entry.is_a?(::Hash) entry.to_h.each_with_object([]) do |(key, value), memo| @@ -125,8 +125,8 @@ def entry_to_pairs(entry) end end - def sub_field_label(sub_field) - I18n.t("hyrax.compound_fields.#{field}.#{sub_field}", default: sub_field.to_s.humanize) + def sub_property_label(sub_property) + I18n.t("hyrax.compound_fields.#{field}.#{sub_property}", default: sub_property.to_s.humanize) end end end diff --git a/app/search_builders/hyrax/compound_work_picker_builder.rb b/app/search_builders/hyrax/compound_work_picker_builder.rb index 78299d3f50..ff9470b296 100644 --- a/app/search_builders/hyrax/compound_work_picker_builder.rb +++ b/app/search_builders/hyrax/compound_work_picker_builder.rb @@ -2,10 +2,14 @@ module Hyrax ## - # Search builder for the compound `work_or_url` sub-field's work picker. Finds - # works the current user can read (it subclasses {Hyrax::SearchBuilder}, so - # permission filtering is retained), matching any indexed query term OR a - # partial/prefix title. + # Search builder for the compound `work_or_url` sub-property's picker. Finds + # works *and* collections the current user can read (it subclasses + # {Hyrax::SearchBuilder}, so permission filtering is retained), matching any + # indexed query term OR a partial/prefix title. + # + # {Hyrax::FilterByType#models} already includes both work and collection + # classes, so no `only_works?`/`only_collections?` override is needed — the + # default type filter admits both. class CompoundWorkPickerBuilder < Hyrax::SearchBuilder include Hyrax::FilterByType @@ -16,10 +20,6 @@ def initialize(context) @q = context.params[:q] end - def only_works? - true - end - # ORs a multi-field term match with a prefix-wildcard title match. The rest # of the processor chain still applies the permission and type filters. def filter_on_any_term_or_partial_title(solr_parameters) diff --git a/app/services/hyrax/compound_entry_validation.rb b/app/services/hyrax/compound_entry_validation.rb index b8d5d04e26..926ed26616 100644 --- a/app/services/hyrax/compound_entry_validation.rb +++ b/app/services/hyrax/compound_entry_validation.rb @@ -7,11 +7,12 @@ module Hyrax # Pure validation logic for a single compound's entries, decoupled from # ActiveModel and Reform so it can be reused (e.g. a future Bulkrax-side # check) and unit-tested directly. {Hyrax::CompoundEntryValidator} wraps this - # for the form layer. See documentation/forms/compound_fields.md. + # for the form layer. See documentation/compound_fields.md. # # Rules (driven by the normalized definition from {Hyrax::CompoundSchema}): # * a compound marked `required` must have at least one populated row; - # * every populated row must fill all of the compound's `required` sub-fields. + # * every populated row must fill all of the compound's `required` + # sub-properties. # # Rows are the post-populator persisted hashes (all-blank rows already # dropped), so a no-required compound with no rows is valid. @@ -25,11 +26,11 @@ def initialize(definition, entries) # @return [Array] one violation per problem, each # `{ type:, missing: [keys] }`. Empty when the compound is valid. - # `type` is `:required_but_empty` or `:missing_required_subfields`. + # `type` is `:required_but_empty` or `:missing_required_subproperties`. def violations return [{ type: :required_but_empty, missing: required_keys }] if required_but_empty? - rows_missing_required.map { |missing| { type: :missing_required_subfields, missing: missing } } + rows_missing_required.map { |missing| { type: :missing_required_subproperties, missing: missing } } end # @return [Boolean] @@ -42,7 +43,7 @@ def valid? attr_reader :definition, :entries def required_keys - definition.fetch(:subfields, {}).select { |_k, spec| spec[:required] }.keys + definition.fetch(:subproperties, {}).select { |_k, spec| spec[:required] }.keys end def required_but_empty? diff --git a/app/services/hyrax/compound_schema.rb b/app/services/hyrax/compound_schema.rb index d45fe1ece6..aede55fc9d 100644 --- a/app/services/hyrax/compound_schema.rb +++ b/app/services/hyrax/compound_schema.rb @@ -5,13 +5,16 @@ module Hyrax # @api public # # Reads the compound metadata declarations off a resource's schema. A compound - # is a `type: hash, multiple: true` attribute carrying a `subfields:` map; the + # is a `type: hash, multiple: true` parent property; its members are declared + # as separate properties pointing back with `subproperty_of: `. The # declaration drives the generic form, indexer, and renderer so a hierarchical - # field can be defined in YAML alone. See documentation/forms/compound_fields.md. + # field can be defined in YAML alone. See documentation/compound_fields.md. # - # Declarations are read from each attribute's Dry type `meta`, which both - # schema loaders populate identically, so the result is the same in both flex - # modes. + # The schema loaders exclude subproperties from a resource's real attributes + # (so they get no accessor of their own) but fold each compound's members into + # the parent's Dry type `meta` (`subproperties:`). This class reads that meta — + # off the resource's own schema — so resolution is identical in both flex + # modes and needs no knowledge of which schema file the resource used. class CompoundSchema # rubocop:disable Metrics/ClassLength ## # Build a CompoundSchema for a resource instance or class. @@ -50,8 +53,9 @@ def self.solr_document_schema_sources(document) end private_class_method :solr_document_schema_sources - # The `{ name => dry_type }` attribute map for a class at a flexible schema - # version; nil when the loader is unavailable (non-flexible installs). + # The `{ name => dry_type }` attribute map (with folded subproperties in + # meta) for a class at a flexible schema version; nil when the loader is + # unavailable (non-flexible installs). def self.flexible_attributes_for(klass, version) loader = Hyrax::Schema.m3_schema_loader loader.attributes_for(schema: klass.name, version: version, contexts: []) @@ -79,14 +83,15 @@ def self.schema_sources_for(resource) attr_reader :schema_sources # @param [Array<#each>] schema_sources one or more Dry schemas (each a - # collection of property types responding to `name` and `meta`). + # collection of property types responding to `name` and `meta`), or a + # `{ name => dry_type }` Hash (the loader's `attributes_for` output). def initialize(*schema_sources) @schema_sources = schema_sources.flatten.compact end ## - # @return [Boolean] whether the given attribute is a compound (declares - # `subfields:`) + # @return [Boolean] whether the given attribute is a compound (a `type: hash` + # parent with at least one subproperty) def compound?(attribute_name) definitions.key?(attribute_name.to_sym) end @@ -122,8 +127,8 @@ def card?(attribute_name) # @param [#to_sym] attribute_name # # @return [Hash{Symbol => Object}, nil] the compound's declaration: - # `{ subfields:, groups:, index_subfields: }`, or nil when the attribute - # is not a compound. + # `{ subproperties:, groups: }`, or nil when the attribute is not a + # compound. def definition_for(attribute_name) definitions[attribute_name.to_sym] end @@ -131,12 +136,12 @@ def definition_for(attribute_name) ## # @param [#to_sym] attribute_name # - # @return [Array] the ordered sub-field keys declared for the + # @return [Array] the ordered sub-property keys declared for the # compound (empty when not a compound). - def subfield_keys(attribute_name) + def subproperty_keys(attribute_name) definition = definition_for(attribute_name) return [] unless definition - definition[:subfields].keys + definition[:subproperties].keys end ## @@ -147,12 +152,19 @@ def required?(attribute_name) end ## - # @return [Array] the sub-field keys declared `required: true` for + # @return [Boolean] whether the compound declares `form: { primary: true }` + # (shown in the form's primary section rather than "Additional fields"). + def primary?(attribute_name) + definition_for(attribute_name)&.dig(:primary) || false + end + + ## + # @return [Array] the sub-property keys declared `required: true` for # the compound (each must be filled in every populated row). - def required_subfield_keys(attribute_name) + def required_subproperty_keys(attribute_name) definition = definition_for(attribute_name) return [] unless definition - definition[:subfields].select { |_key, spec| spec[:required] }.keys + definition[:subproperties].select { |_key, spec| spec[:required] }.keys end ## @@ -164,23 +176,28 @@ def definitions private + # Assemble one normalized definition per compound parent. A parent is an + # attribute whose type meta carries a folded `subproperties:` map (the + # loaders fold each compound's `subproperty_of` members into the parent's + # meta; see {SchemaLoader#attributes_for}). A `type: hash` with no + # subproperties (e.g. redirects) is not a compound and is skipped. def build_definitions schema_sources.each_with_object({}) do |schema, memo| name_meta_pairs(schema).each do |name, meta| next if meta.nil? || memo.key?(name) config = meta.with_indifferent_access - subfields = config['subfields'] - next if subfields.blank? + children = config['subproperties'] + next if children.blank? - memo[name] = normalize(config, subfields) + memo[name] = normalize(config, children) end end end # `[name, meta]` pairs for a schema source, which is either a Dry schema (an # iterable of properties with `name`/`meta`) or a `{ name => dry_type }` Hash - # (the `SchemaLoader#attributes_for` output used for version resolution). + # (the loader's `attributes_for` output). def name_meta_pairs(schema) if schema.is_a?(::Hash) schema.map { |name, type| [name.to_sym, (type.meta if type.respond_to?(:meta))] } @@ -191,22 +208,30 @@ def name_meta_pairs(schema) end end - # Normalizes the raw declaration into the symbol-keyed shape the form, - # indexer, and renderer consume. See documentation/forms/compound_fields.md - # for the meaning of each sub-field key. - def normalize(config, subfields) - sub = subfields.each_with_object({}) { |(key, opts), memo| memo[key.to_s] = normalize_subfield(opts) } + # Normalizes a parent config plus its (folded) child configs into the + # symbol-keyed shape the form, indexer, and renderer consume. See + # documentation/compound_fields.md for the meaning of each key. + def normalize(config, children) + sub = children.each_with_object({}) { |(key, opts), memo| memo[key.to_s] = normalize_subproperty(opts) } view = config['view'] display_mode = view.is_a?(Hash) && view['display'].to_s == 'card' ? :card : :inline - { subfields: sub, - groups: normalize_groups(config['groups'], sub.keys), + { subproperties: sub, + groups: normalize_groups(config['groups'], children), display_mode: display_mode, required: compound_required?(config), + primary: compound_primary?(config), display_label: normalize_display_label(config) } end + # Whether the compound declares `form: { primary: true }` (renders in the + # form's primary section rather than "Additional fields"). Default false. + def compound_primary?(config) + form = config['form'] + form.is_a?(Hash) && truthy?(form['primary']) + end + # The declared display label as `{ locale => String }` (the m3 # `display_label` shape), or nil when none is declared. def normalize_display_label(config) @@ -215,14 +240,18 @@ def normalize_display_label(config) raw.is_a?(Hash) ? raw.with_indifferent_access : { default: raw.to_s }.with_indifferent_access end - def normalize_subfield(opts) + def normalize_subproperty(opts) opts = (opts.is_a?(Hash) ? opts : {}).with_indifferent_access + form = opts['form'].is_a?(Hash) ? opts['form'] : {} { type: (opts['type'] || 'string').to_s, authority: opts['authority']&.to_s, values: normalize_values(opts['values']), index_keys: normalize_index_keys(opts), display: opts.fetch('display', true) != false, - required: truthy?(opts['required']) } + required: truthy?(opts['required']), + group: opts['group']&.to_s, + cols: (form['cols'] || DEFAULT_COLS).to_i, + as: form['as']&.to_s } end # Whether the compound itself is required (at least one row must exist). @@ -261,14 +290,25 @@ def normalize_values(values) end end - def normalize_groups(groups, all_keys) - return [{ label: nil, cols: 6, fields: all_keys }] if groups.blank? + DEFAULT_COLS = 6 + + # Builds the ordered group list for a compound from its children's `group:` + # membership and the parent's `groups:` label metadata. Groups appear in the + # order their first member is declared (document order); a child with no + # `group:` falls in a leading default (unlabeled) group. Field order within a + # group is document order. `group_meta` is `{ key => { label: } }`. + def normalize_groups(group_meta, children) + labels = (group_meta.is_a?(Hash) ? group_meta : {}).with_indifferent_access + ordered = {} + children.each_key do |child_name| + key = (children[child_name]['group'] if children[child_name].is_a?(Hash)).to_s + (ordered[key] ||= []) << child_name.to_s + end - Array(groups).map do |group| - group = group.with_indifferent_access - { label: group['label'], - cols: (group['cols'] || 6).to_i, - fields: Array(group['fields']).map(&:to_s) } + ordered.map do |key, fields| + { key: key.presence, + label: key.present? ? (labels.dig(key, 'label') || key.to_s.humanize) : nil, + fields: fields } end end end diff --git a/app/services/hyrax/compound_subfield_labeler.rb b/app/services/hyrax/compound_subproperty_labeler.rb similarity index 79% rename from app/services/hyrax/compound_subfield_labeler.rb rename to app/services/hyrax/compound_subproperty_labeler.rb index 4d0e2d4663..caed10e306 100644 --- a/app/services/hyrax/compound_subfield_labeler.rb +++ b/app/services/hyrax/compound_subproperty_labeler.rb @@ -4,14 +4,14 @@ module Hyrax ## # @api public # - # Resolves a `controlled` compound sub-field's stored id to its display term - # (via inline `values:` or a QA `authority:`). Non-controlled sub-fields and + # Resolves a `controlled` compound sub-property's stored id to its display term + # (via inline `values:` or a QA `authority:`). Non-controlled sub-properties and # ids with no matching term fall back to the value itself. - class CompoundSubfieldLabeler + class CompoundSubpropertyLabeler ## - # @param spec [Hash, nil] the normalized sub-field spec + # @param spec [Hash, nil] the normalized sub-property spec # (`{ type:, authority:, values: }`) from {Hyrax::CompoundSchema} - # @param value [Object] the stored value (id for controlled sub-fields) + # @param value [Object] the stored value (id for controlled sub-properties) # # @return [String] the term to display def self.label_for(spec, value) @@ -35,7 +35,7 @@ def self.label_from_values(values, value) def self.label_from_authority(authority_name, value) Hyrax::TolerantSelectService.new(authority_name).label(value.to_s) { value.to_s } rescue StandardError => e - Hyrax.logger.debug("CompoundSubfieldLabeler: #{authority_name}: #{e.message}") + Hyrax.logger.debug("CompoundSubpropertyLabeler: #{authority_name}: #{e.message}") value.to_s end private_class_method :label_from_authority diff --git a/app/services/hyrax/compound_work_resolver.rb b/app/services/hyrax/compound_work_resolver.rb index f25235b94f..450df55222 100644 --- a/app/services/hyrax/compound_work_resolver.rb +++ b/app/services/hyrax/compound_work_resolver.rb @@ -4,7 +4,7 @@ module Hyrax ## # @api public # - # Helpers for the `work_or_url` compound sub-field, whose stored value is + # Helpers for the `work_or_url` compound sub-property, whose stored value is # either an external URL or an internal work id. Distinguishes the two and # resolves an internal work id to a display title (from Solr) and a show path. class CompoundWorkResolver @@ -19,46 +19,75 @@ def self.url?(value) # @return [Array(String, String)] the work's title (falling back to the id) # and its show path def self.title_and_path(id) - [title_for(id), path_for(id)] + doc = indexed_doc_for(id) + [doc ? title_from(doc, id) : id.to_s, path_for_doc(doc, id)] end ## - # Resolve an internal work id to its display title and show path, but only - # when a matching work is actually indexed. Returns nil when nothing - # matches, so callers can render a bare, unlinked value rather than a broken - # link to a non-existent work. + # Resolve an internal id to its display title and show path, but only when a + # matching record is actually indexed. Returns nil when nothing matches, so + # callers can render a bare, unlinked value rather than a broken link to a + # non-existent record. # # @param id [String] # @return [Array(String, String), nil] `[title, path]`, or nil when unresolved def self.resolve(id) - title = indexed_title_for(id) - return nil if title.nil? - [title, path_for(id)] + doc = indexed_doc_for(id) + return nil if doc.nil? + [title_from(doc, id), path_for_doc(doc, id)] end def self.title_for(id) - indexed_title_for(id) || id.to_s + doc = indexed_doc_for(id) + doc ? title_from(doc, id) : id.to_s end - # The indexed title for a work id, or nil when no such work is indexed - # (distinguishes "resolved to a real work" from "no match"). - def self.indexed_title_for(id) - doc = Hyrax::SolrService.query("{!field f=id}#{id}", fl: 'title_tesim', rows: 1).first - return nil if doc.nil? - Array(doc['title_tesim']).first.presence || id.to_s + def self.path_for(id) + doc = indexed_doc_for(id) + path_for_doc(doc, id) + end + + # The indexed Solr document for an id (as a {SolrDocument} so it carries the + # Wings-aware model resolution), or nil when none is indexed (distinguishes + # "resolved to a real record" from "no match"). + def self.indexed_doc_for(id) + raw = Hyrax::SolrService.query("{!field f=id}#{id}", fl: 'id,title_tesim,has_model_ssim', rows: 1).first + raw && ::SolrDocument.new(raw) rescue StandardError => e - Hyrax.logger.debug("CompoundWorkResolver.indexed_title_for(#{id}): #{e.message}") + Hyrax.logger.debug("CompoundWorkResolver.indexed_doc_for(#{id}): #{e.message}") nil end - private_class_method :indexed_title_for + private_class_method :indexed_doc_for - # The model-agnostic Blacklight show route (`/catalog/:id`) links any - # indexed work without knowing its class. - def self.path_for(id) - Rails.application.routes.url_helpers.solr_document_path(id) + def self.title_from(doc, id) + Array(doc['title_tesim']).first.presence || id.to_s + end + private_class_method :title_from + + # The show path for an indexed record. Classification uses the document's + # own Wings-aware predicates (`collection?`/`work?`, which resolve through + # `hydra_model` and so honor the `valkyrie_transition` mapping): + # * a collection -> the engine collection show route (`/collections/:id`); + # * a work -> its work show route, named by the routed model's + # `singular_route_key` (e.g. `hyrax_generic_work_path` -> + # `/concern/generic_works/:id`); + # * anything else -> the model-agnostic catalog route. + def self.path_for_doc(doc, id) + app = Rails.application.routes.url_helpers + return app.solr_document_path(id) if doc.nil? + + if doc.collection? + Hyrax::Engine.routes.url_helpers.collection_path(id) + elsif doc.work? + helper = "#{doc.hydra_model.model_name.singular_route_key}_path" + app.respond_to?(helper) ? app.public_send(helper, id) : app.solr_document_path(id) + else + app.solr_document_path(id) + end rescue StandardError => e - Hyrax.logger.debug("CompoundWorkResolver.path_for(#{id}): #{e.message}") - "/catalog/#{id}" + Hyrax.logger.debug("CompoundWorkResolver.path_for_doc(#{id}): #{e.message}") + Rails.application.routes.url_helpers.solr_document_path(id) end + private_class_method :path_for_doc end end diff --git a/app/services/hyrax/flexible_schema_validator_service.rb b/app/services/hyrax/flexible_schema_validator_service.rb index 45db289b9c..f79a3c434d 100644 --- a/app/services/hyrax/flexible_schema_validator_service.rb +++ b/app/services/hyrax/flexible_schema_validator_service.rb @@ -134,8 +134,8 @@ def validate_redirects end # Validates compound (hierarchical) metadata properties — those declaring - # `subfields:` — for well-formed sub-fields and correct (per-sub-field) - # indexing declaration. + # `subproperties:` — for well-formed sub-properties and correct + # (per-sub-property) indexing declaration. # # @return [void] def validate_compound diff --git a/app/services/hyrax/flexible_schema_validators/compound_validator.rb b/app/services/hyrax/flexible_schema_validators/compound_validator.rb index e2bda68a32..563756838c 100644 --- a/app/services/hyrax/flexible_schema_validators/compound_validator.rb +++ b/app/services/hyrax/flexible_schema_validators/compound_validator.rb @@ -5,11 +5,12 @@ module FlexibleSchemaValidators ## # @api private # - # Validates compound metadata properties (a `type: hash` property declaring - # `subfields:` — see {Hyrax::CompoundSchema}) in an m3 profile at save time, - # so a misconfiguration fails with a clear message instead of producing dead - # Solr fields or unrenderable values. See - # documentation/forms/compound_fields.md for the rules. + # Validates compound metadata in an m3 profile at save time, so a + # misconfiguration fails with a clear message instead of producing dead Solr + # fields or unrenderable values. A compound is a `type: hash` parent with + # members declared as separate properties pointing back via + # `subproperty_of: ` — see {Hyrax::CompoundSchema}. See + # documentation/compound_fields.md for the rules. class CompoundValidator ## # @param profile [Hash] the flexible metadata profile @@ -21,45 +22,45 @@ def initialize(profile:, errors:) # @return [void] def validate! - compound_properties.each do |name, config| - validate_subfields(name, config['subfields']) - validate_no_top_level_indexing(name, config) - end + subproperties.each { |name, config| validate_subproperty(name, config) } + compound_parents.each { |name, config| validate_no_top_level_indexing(name, config) } end private - # Compounds are detected by `subfields:` presence (not `type`), so other - # hash fields like redirects stay out of scope. - def compound_properties - (@profile&.dig('properties') || {}).select do |_name, config| - config.is_a?(Hash) && config['subfields'].present? - end + def properties + @properties ||= (@profile&.dig('properties') || {}) end - def validate_subfields(name, subfields) - unless subfields.is_a?(Hash) - @errors << t('subfields_not_hash', property: name) - return - end + # Entries that declare `subproperty_of:` — the compound members. + def subproperties + properties.select { |_name, config| config.is_a?(Hash) && config['subproperty_of'].present? } + end - subfields.each { |sub_name, sub_config| validate_subfield(name, sub_name, sub_config) } + # `type: hash` parents that have at least one subproperty pointing at them. + # (A `type: hash` with no children — e.g. redirects — is not a compound.) + def compound_parents + parent_names = subproperties.values.filter_map { |c| c['subproperty_of'].to_s }.to_set + properties.select { |name, config| parent_names.include?(name.to_s) && config.is_a?(Hash) && config['type'].to_s == 'hash' } end - def validate_subfield(name, sub_name, sub_config) - unless sub_config.is_a?(Hash) - @errors << t('subfield_not_hash', property: name, subfield: sub_name, actual: sub_config.class.to_s) + def validate_subproperty(name, config) + parent_name = config['subproperty_of'].to_s + parent = properties[parent_name] + if parent.nil? || !parent.is_a?(Hash) || parent['type'].to_s != 'hash' + @errors << t('unknown_parent', property: name, parent: parent_name) return end - return unless sub_config['type'].to_s == 'controlled' - return if sub_config['authority'].present? || sub_config['values'].present? + return unless config['type'].to_s == 'controlled' + return if config['authority'].present? || config['values'].present? - @errors << t('controlled_without_source', property: name, subfield: sub_name) + @errors << t('controlled_without_source', property: name) end - # A top-level `indexing:` would point the catalog at a `_tesim` - # field the indexer never writes; indexing is per sub-field. + # A top-level `indexing:` on a parent would point the catalog at a + # `_tesim` field the indexer never writes; indexing is declared + # per subproperty. def validate_no_top_level_indexing(name, config) return if config['indexing'].blank? @errors << t('top_level_indexing', property: name) diff --git a/app/services/hyrax/m3_schema_loader.rb b/app/services/hyrax/m3_schema_loader.rb index 07554e1137..78ff74183d 100644 --- a/app/services/hyrax/m3_schema_loader.rb +++ b/app/services/hyrax/m3_schema_loader.rb @@ -39,10 +39,37 @@ def current_version # @param [#to_s] schema_name # @return [Enumerable Hash}] all attribute configs for the schema, + # INCLUDING subproperties (see {SchemaLoader#raw_attribute_configs}), with + # the same context filtering {#definitions} applies. + def raw_definitions(schema_name, version, contexts = nil) + contextual_attributes(schema_name, version, contexts).to_h + rescue ActiveRecord::StatementInvalid + Rails.logger.error "Skipping definition load for migrations to run" + {} + end + + # The schema's attributes after context filtering (but before the + # subproperty exclusion {#definitions} applies). Yields `[name, config]`. + def contextual_attributes(schema_name, version, contexts = nil) + attributes = resolve_schema(version)&.attributes_for(schema_name) attributes ||= fallback_schema_for(schema_name) - attributes.map do |name, config| + attributes.filter_map do |name, config| # We might be able to consolidate these conditions, but they have been kept separate to make it easier to reason about # If there is a context filter on the metadata field and no context is set, skip it next if contexts.blank? && config['context'].present? @@ -51,11 +78,19 @@ def definitions(schema_name, version, contexts = nil) next if contexts.present? && config['context'].present? && !(Array(contexts) & Array(config['context'])).any? # Wew, we are in the clear to use this field - M3AttributeDefinition.new(name, config) - end.compact - rescue ActiveRecord::StatementInvalid - Rails.logger.error "Skipping definition load for migrations to run" - [] + [name, config] + end + end + + # The flexible schema to read attributes from: the requested version, else a + # freshly-created default, else the latest existing row. Any of these may be + # nil during early boot or on a fresh DB, so callers must guard with `&.`. + # (`create_default_schema` returns nil when a row already exists; `find_by` + # misses when `version` is unknown.) + def resolve_schema(version) + Hyrax::FlexibleSchema.find_by(id: version) || + Hyrax::FlexibleSchema.create_default_schema || + Hyrax::FlexibleSchema.order(:created_at).last end # rubocop:disable Metrics/MethodLength diff --git a/app/services/hyrax/schema_loader.rb b/app/services/hyrax/schema_loader.rb index a23deb946c..6133be3a01 100644 --- a/app/services/hyrax/schema_loader.rb +++ b/app/services/hyrax/schema_loader.rb @@ -16,8 +16,16 @@ class UndefinedSchemaError < ArgumentError; end # @return [Hash] a map from attribute names to # types def attributes_for(schema:, version: 1, contexts: nil) + children = subproperties_by_parent(schema, version, contexts) definitions(schema, version, contexts).each_with_object({}) do |definition, hash| - hash[definition.name] = definition.type.meta(definition.config) + # Fold a compound parent's subproperties (which are excluded from the + # real attributes) into the parent's type metadata, so the resource's + # own schema carries them for Hyrax::CompoundSchema to read — without + # the subproperties becoming standalone attributes. + config = definition.config + subs = children[definition.name.to_s] + config = config.merge('subproperties' => subs) if subs.present? + hash[definition.name] = definition.type.meta(config) end end @@ -45,6 +53,44 @@ def index_rules_for(schema:, version: 1, contexts: nil) end end + ## + # The raw per-attribute configs for a schema, INCLUDING compound + # subproperties (entries declaring `subproperty_of:`). Unlike + # {#attributes_for} et al. — which exclude subproperties so they never become + # standalone resource attributes — this returns everything declared, so + # {Hyrax::CompoundSchema} can gather each parent compound's subproperties. + # + # @return [Hash{Symbol => Hash}] `{ attribute_name => raw_config_hash }` + def raw_attribute_configs(schema:, version: 1, contexts: nil) + raw_definitions(schema, version, contexts).each_with_object({}) do |(name, config), hash| + hash[(config['name'] || name).to_sym] = config + end + end + + # @return [Boolean] whether a raw per-attribute config is a compound + # subproperty (declares `subproperty_of:`), which must be excluded from the + # resource's real attributes. + def subproperty_config?(config) + config.is_a?(Hash) && config['subproperty_of'].present? + end + + # Subproperty configs grouped under their parent compound, in document + # order: `{ parent_name => { child_name => child_config } }`. Used to fold + # each compound's members into its parent's type metadata (see + # {#attributes_for}). + def subproperties_by_parent(schema, version, contexts) + raw_definitions(schema, version, contexts).each_with_object({}) do |(name, config), memo| + next unless subproperty_config?(config) + + parent = config['subproperty_of'].to_s + child_name = (config['name'] || name).to_s + (memo[parent] ||= {})[child_name] = config + end + rescue StandardError => e + Hyrax.logger.debug("subproperties_by_parent(#{schema}): #{e.message}") + {} + end + def current_version 1 end @@ -162,7 +208,7 @@ def self.of(type) # # Recognized values: # - `id`, `uri`, `date_time` — Valkyrie type shortcuts. - # - `hash` — for attributes whose entries carry multiple sub-fields + # - `hash` — for attributes whose entries carry multiple sub-properties # (e.g. redirects, with path / canonical / sequence). Use this # instead of nesting a Valkyrie::Resource. See # `documentation/redirects.md` for a worked example. diff --git a/app/services/hyrax/simple_schema_loader.rb b/app/services/hyrax/simple_schema_loader.rb index 9138ad5ddf..c9ce31dff9 100644 --- a/app/services/hyrax/simple_schema_loader.rb +++ b/app/services/hyrax/simple_schema_loader.rb @@ -27,11 +27,23 @@ def permissive_schema_for_valkrie_adapter # @param [#to_s] schema_name # @return [Enumerable Hash}] all attribute configs, INCLUDING + # subproperties (see {SchemaLoader#raw_attribute_configs}). + def raw_definitions(schema_name, _version, _contexts = nil) + schema_config(schema_name)['attributes'] + end + ## # @param [#to_s] schema_name # @return [Hash] @@ -70,6 +82,11 @@ def disabled_schemas def predicate_pairs(ret_hsh, schema_name) schema_config(schema_name)['attributes'].each do |name, config| + # Compound subproperties are not standalone resource attributes (see + # SchemaLoader#definitions), so they have no predicate of their own and + # are excluded from the permissive Valkyrie schema. + next if subproperty_config?(config) + predicate = RDF::URI(config['predicate']) if ret_hsh[name].blank? ret_hsh[name.to_sym] = predicate diff --git a/app/validators/hyrax/compound_entry_validator.rb b/app/validators/hyrax/compound_entry_validator.rb index eea7da423e..952ccd40f1 100644 --- a/app/validators/hyrax/compound_entry_validator.rb +++ b/app/validators/hyrax/compound_entry_validator.rb @@ -3,12 +3,13 @@ module Hyrax # Validates every compound (hierarchical) metadata attribute on a form, # blocking save when a required compound has no row or a populated row omits a - # required sub-field. Adds one error per compound, keyed on the compound name. + # required sub-property. Adds one error per compound, keyed on the compound + # name. # # Record-level (not an EachValidator) because the compound set is schema-driven # and not known at form-class-definition time. The per-compound rules live in # the reusable {Hyrax::CompoundEntryValidation}. See - # documentation/forms/compound_fields.md. + # documentation/compound_fields.md. class CompoundEntryValidator < ActiveModel::Validator def validate(record) return unless Hyrax.config.compound_metadata_enabled? @@ -49,14 +50,14 @@ def validate_compound(record, name, definition) def message_for(name, violation) I18n.t("hyrax.compound_fields.errors.#{violation[:type]}", compound: compound_label(name), - fields: subfield_labels(name, violation[:missing])) + fields: subproperty_labels(name, violation[:missing])) end def compound_label(name) I18n.t("hyrax.compound_fields.#{name}.label", default: name.to_s.humanize) end - def subfield_labels(name, keys) + def subproperty_labels(name, keys) Array(keys).map do |key| I18n.t("hyrax.compound_fields.#{name}.#{key}", default: key.to_s.humanize) end.join(', ') diff --git a/app/views/hyrax/base/_form_metadata.html.erb b/app/views/hyrax/base/_form_metadata.html.erb index acd80ce473..bbf883d693 100644 --- a/app/views/hyrax/base/_form_metadata.html.erb +++ b/app/views/hyrax/base/_form_metadata.html.erb @@ -3,8 +3,10 @@ <%= render_edit_field_partial(term, f: f) %> <% end %>
- <% if f.object.respond_to?(:compound_terms) %> - <% f.object.compound_terms.each do |term| %> + <%# Primary compounds (form: { primary: true }) render in the primary + section; non-primary compounds render in "Additional fields" below. %> + <% if f.object.respond_to?(:primary_compound_terms) %> + <% f.object.primary_compound_terms.each do |term| %> <%= render_compound_field(f, term) %> <% end %> <% end %> @@ -21,5 +23,10 @@ <% f.object.secondary_terms.each do |term| %> <%= render_edit_field_partial(term, f: f) %> <% end %> + <% if f.object.respond_to?(:secondary_compound_terms) %> + <% f.object.secondary_compound_terms.each do |term| %> + <%= render_compound_field(f, term) %> + <% end %> + <% end %> <% end %> diff --git a/app/views/hyrax/compounds/_compound_row.html.erb b/app/views/hyrax/compounds/_compound_row.html.erb index a5ea6cd834..b771fa36d3 100644 --- a/app/views/hyrax/compounds/_compound_row.html.erb +++ b/app/views/hyrax/compounds/_compound_row.html.erb @@ -3,24 +3,23 @@ Locals: f - form builder for the parent resource compound_name - Symbol, e.g. :contributors - definition - Hash{ subfields:, groups:, index_subfields: } + definition - Hash{ subproperties:, groups: } row - Hash of persisted values, or nil for a new row index - Integer position, or the literal "__INDEX__" placeholder when rendered inside the JS template row_label_singular - "Contributor", "License", etc. - Each sub-field renders according to its declared `type:` (string, + 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. %> -<% subfields = definition[:subfields] %> +<% subproperties = definition[:subproperties] %> <% groups = definition[:groups] %> -<% row_position = index.is_a?(String) ? '' : (index.to_i + 1).to_s %> <% row_value = ->(key) { row.is_a?(Hash) ? (row[key] || row[key.to_sym]) : nil } %> -
+
- <%= row_label_singular %> <%= row_position %> + <%= row_label_singular %>