Add compound (hierarchical) metadata foundation#7479
Conversation
Hyrax can read compound (hierarchical) metadata declarations off a resource's schema, returning each compound's sub-fields, groups, and display mode in both flexible and non-flexible modes. This is the read-only foundation the compound form, indexer, and show renderer build on; the compound_metadata_enabled? config flag (default on) gates whether the rest of the feature activates. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resource edit forms render a repeatable, schema-driven section per compound attribute, with add/remove-row controls and a generic populator that converts the nested-attributes payload to the persisted array-of-hashes shape. Each sub-field renders by its declared type (text, controlled select, url, or a work-or-url picker). Compounds are kept out of the primary/secondary scalar terms so they render only through the compound section. The wiring is a no-op until a schema declares a compound, in both flexible and non-flexible modes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Compound metadata round-trips through storage: a read-path concern guards against Valkyrie's single-element-array key-splay on reload, and an indexer mixin writes each sub-field's declared Solr fields plus a JSON display blob the show page reads from. A SolrDocument compound_attribute declarer parses that blob back into an array of hashes. The indexer mixin is included on the base work and collection indexers but is a no-op until a schema declares a compound. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Show pages render compound metadata through a generic renderer: each
entry's populated sub-fields display as a labeled block, controlled
sub-fields show their term rather than the stored id, urls are linked,
and a work-or-url value links to the internal work or the external URL.
A compound can render inline in the metadata list or, with
view: { display: card }, as its own card on works and collections. The
work-or-url picker searches readable works by any indexed term or a
partial title.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Hyrax ships four sample compounds (agent, identifiers, compound_rights, relationships) on works and collections, demonstrating every sub-field type. They are declared once in the schema — compound_metadata.yaml in non-flexible mode and the m3 profile in flexible mode — and wired onto the base work, collection, and their SolrDocument readers. This turns the feature on (gated by compound_metadata_enabled?, default true). A flexible-mode validator rejects a misconfigured compound when an m3 profile is saved. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a reference for declaring compound (hierarchical) metadata in schema YAML — sub-field types, indexing, inline vs. card display, the work-or-url picker, profile validation, and Bulkrax round-tripping — and points the field-behaviors guide at it as the preferred path for new multi-sub-field compounds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The sample compound now uses the attribute name `agents`, avoiding a collision with the internal ACL `agent` attribute that shadowed it in the Valkyrie permissive schema. The collision dropped one entry from the permissive schema (and broke its spec); the sub-field names (agent_name, agent_role) are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Test Results 17 files ± 0 17 suites ±0 3h 25m 51s ⏱️ - 4m 57s Results for commit 197e231. ± Comparison against base commit edb0db3. This pull request removes 452 and adds 617 tests. Note that renamed tests count towards both.♻️ This comment has been updated with latest results. |
Compound metadata fields no longer show up twice on the collection form, the collection creation page now displays validation errors, and those error messages read cleanly. The collection form was listing each compound field both in its own section and again as an ordinary single field; when a sub-field was required, the duplicate stopped the form from saving. The create page also wasn't showing error messages at all, and the messages it built read awkwardly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A compound can now mark sub-fields required (filled in every entry) and mark itself required (at least one entry). On save the form blocks with one error per compound, naming the field; required fields show a * on the form. Works the same in both flexible and non-flexible modes, on works and collections. The rules live in Hyrax::CompoundEntryValidation, a plain object decoupled from the form so they can be reused and tested directly; Hyrax::CompoundEntryValidator wires it onto the resource form. The shipped sample compounds require their sub-fields within an entry but remain optional overall. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Compound metadata cards now render on show pages in flexible mode. The card helpers resolved the compound declarations from the resource class, but in flexible mode the class carries none — they are applied per instance at load — so no cards appeared. They now resolve from the show document's indexed schema_version, reading that version's attributes without loading the resource; non-flexible mode still reads the class. Also corrects a collection-controller spec that doubled the form errors object with a class lacking full_messages, matching the real Reform errors object the controller now reads. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Inline compound metadata (e.g. agents, identifiers) now appears on a collection's show page in flexible mode. The presenter resolved its displayed terms and compound readers from the collection class, but a flexible schema is per-resource — keyed by the document's indexed schema_version and mutable at runtime — so the class often carries none. It now resolves them from the backing document, the same way the compound cards already do.
The instance term list now derives from the class-level terms plus the collection's compound terms, instead of a hardcoded constant. This restores the effect of a downstream override of the class `terms` method (e.g. one that removes a term), which the previous instance implementation bypassed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three fixes for compound cards on show pages: - Sub-field specs now resolve from the backing document, not the class, so in flexible mode a card's controlled terms display their labels and a work-or-url value links correctly (the class carries no compounds in flexible mode, so the renderer was getting raw values). - A work-or-url value that is neither a URL nor a resolvable indexed work now renders as plain text instead of a broken link. - Compound values sit flush with the value column instead of carrying a fixed indent that misaligned under downstream layouts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A compound rendered as a card showed its label twice — once as the card title and again as the field label inside the body. The card body now renders the entry values only, via a new value_only option on attribute_to_html, leaving the card title as the single label.
A long unbreakable value such as a rights-statement or license URL link was shrinking the flex label column and shifting the value column left, so compound rows no longer lined up with the rows above them. Hold the label column fixed and let the value column shrink so long URLs wrap within it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Compounds use a declared display_label, resolved the same way ordinary properties are (the rights sample now shows "Rights"); the card title uses it and no longer duplicates the field label. - A controlled sub-field whose value is a URI (rights statement, license) links the term to that URI; a work-or-url value that resolves to no indexed work renders as plain text instead of a broken link. - Inline compound values align with the surrounding metadata: work-page values share the ul.tabular wrapper, and collection values resolve their definition from the document so they render correctly in flexible mode. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The SolrDocument now exposes a reader for every compound in its active schema, resolved dynamically. An application that declares its own compounds renders them on the show page with no per-document setup.
The shipped sample compound `agents` is now `participants`. Agents as a term has too many other uses and meanings. Additionally, it also refers to sipity agents, permissions agents, Fedora ACl agents, etc, meaning it could create a collision of terms & break the app.
The SolrDocument now defines a real reader method for each compound it carries (its `<name>_json_ss` blob) when the document is built, so an application's own compounds render on the show page with no per-document declaration, in both flex modes.
Collection show pages now render when a compound is declared for the collection but the record has no entries for it, instead of raising NoMethodError. The collection presenter lists every declared compound among its terms, but the SolrDocument only defines a reader for a compound that has indexed data. Reading a declared-but-empty compound now returns no entries rather than calling an undefined method.
Compound (hierarchical) metadata now calls its nested members "subproperties" instead of "subfields" — the YAML key is `subproperties:`, and the supporting classes, methods, i18n keys, and CSS classes follow the same name. This is a vocabulary rename only; the declaration structure, the normalized internal shape, indexing, rendering, and validation are unchanged. It sets up a later change that expresses subproperties as composable, first-class properties.
A compound's members are now declared as separate top-level properties
that point back to the parent with `subproperty_of: <parent>`, rather
than nested in a `subproperties:` block. Each subproperty carries its
own group membership (`group:`) and form placement (`form: { cols: }`),
and a parent's `groups:` block names the groups. Subproperties are not
standalone resource attributes — the schema loaders fold them into the
parent, so storage, indexing, and rendering are unchanged.
This is the foundation for deeper nesting and for subproperties that
carry their own form/display options. The previous nested `subproperties:`
format is replaced (not kept for back-compat); both flex modes use the
same flat declaration.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Flexible (m3 profile) compound subproperties may omit `display_label`, `available_on`, and `range`: the profile JSON schema exempts entries declaring `subproperty_of`, and a subproperty inherits its parent compound's class scope. The flexible schema model no longer requires a `range` (keeping the declared `type:` when absent) and skips registering subproperties as standalone class attributes. Without this, a profile using the flat compound format fails validation and the app raises on boot building attributes for a subproperty. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A `work_or_url` subproperty now resolves an internal id to the right show page — a work to its work page, a collection to its collection page — instead of the model-agnostic catalog route, using the document's Wings-aware model resolution. The picker search also returns collections alongside works, so a related item can be either. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A compound now renders in the form's primary section when it declares
`form: { primary: true }`, and in "Additional fields" otherwise —
matching how scalar terms split. The work and collection forms render
`primary_compound_terms` up top and `secondary_compound_terms` in the
collapsible section. The shipped samples demonstrate both: participants
and relationships are primary; identifiers and compound_rights are
additional.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The m3 schema loader could call `attributes_for` on a nil schema: `find_by(id: version)` misses on an unknown version and `create_default_schema` returns nil when a row already exists, so on a fresh database or early boot the loader raised `NoMethodError`. It now falls back to the latest schema and, failing that, the default attribute set.
The redirects and compound form populators read their submitted nested-attributes fragment through a shared Hyrax::CompoundRowPlumbing module rather than each defining its own identical fragment-coercion helpers. Each populator keeps its own row-drop rule and value normalization; only the shape coercion (unwrapping ActionController::Parameters) is shared, so form behavior is unchanged in both flex modes.
The compound and field-behavior docs now live at documentation/ (no longer under forms/) and spell out when to declare a compound versus hand-write a field behavior: redirects and based_near stay field behaviors because moving them onto the compound machinery would change persisted shape or behavior, not refactor it. References to the two docs areupdated to the new paths.
🚧 Rework yaml structure for compound metadata
The feature was on by default and had no reason to be toggleable; applications that want to drop the sample compounds override the schema in their own app instead.
Documentation was moved and a few places were missed.
|
Amazing!
Is there (already?) an easy way to provide custom rendering? (with a custom view_component I guess?). Seems likely to be a need for this. |
A work create that fails validation re-renders the form with its errors instead of raising, because rebuild_form no longer wraps a Hyrax::ChangeSet in the legacy FailedSubmissionFormWrapper. The wrapper exists for ActiveFedora forms and reads permitted_params, which a Valkyrie ChangeSet does not define, so wrapping one raised ArgumentError and turned every validation error into a 500. The ChangeSet already holds its submitted, validated state, mirroring the guard already present in after_update_error.
I agree that we need to expand the view abilities... this is likely the next step (and a good stepping off point for anyone else who wants to contribute to this work. Getting the foundation in place was a big enough piece that I just wanted to get the basics going before trying to present them!) |
A compound's form placement (e.g. `form: { primary: true }`) now takes
effect for new and edit forms as soon as a new metadata profile is
loaded, with no process restart, matching how other properties already
behave.
| } | ||
| document.addEventListener('turbolinks:load', bindAll); | ||
|
|
||
| document.addEventListener('click', function(event) { |
There was a problem hiding this comment.
Function has a complexity of 11 complexity
| // rebinding. The sentinel guards against the IIFE running twice (Turbolinks | ||
| // re-evaluates module scripts on some navigation paths), which would stack | ||
| // duplicate listeners. Mirrors hyrax/redirects.js. | ||
| (function() { |
There was a problem hiding this comment.
Move the invocation into the parens that contain the function wrap-iife
| .hyrax-compound-entry + .hyrax-compound-entry { | ||
| margin-top: 0.5rem; | ||
| padding-top: 0.5rem !important; | ||
| border-top: 1px solid $gray-200 !important; |
| // A light divider between entries. | ||
| .hyrax-compound-entry + .hyrax-compound-entry { | ||
| margin-top: 0.5rem; | ||
| padding-top: 0.5rem !important; |
|
|
||
| // A light divider between entries. | ||
| .hyrax-compound-entry + .hyrax-compound-entry { | ||
| margin-top: 0.5rem; |
There was a problem hiding this comment.
Properties should be ordered border-top, margin-top, padding-top
| .hyrax-compound-subproperty, | ||
| .hyrax-compound-subproperty-label, | ||
| .hyrax-compound-subproperty-value { | ||
| border: 0 !important; |
There was a problem hiding this comment.
!important should not be used
Properties should be ordered background, border, margin, padding
| // inline so each sub-property reads as "Label: value" on one wrapping line. | ||
| .hyrax-compound-entry, | ||
| .hyrax-compound-subproperty { | ||
| display: block !important; |
| display: block !important; | ||
| flex-direction: column; | ||
| border: 0 !important; | ||
| padding: 0 !important; |
| .hyrax-compound-values { | ||
| display: block !important; | ||
| flex-direction: column; | ||
| border: 0 !important; |
| // in _collections.scss) that would otherwise turn each entry into a bordered | ||
| // flex row. These rules override it regardless of source order. | ||
| .hyrax-compound-values { | ||
| display: block !important; |
There was a problem hiding this comment.
!important should not be used
Properties should be ordered border, display, flex-direction, max-width, padding
Summary
Introduces compound (hierarchical) metadata — a repeatable list of entries, each a set of named sub-properties — defined entirely in schema YAML with no per-field code, on works and collections.
Details
type: hash, multiple: trueparent property; its members are flat top-level properties pointing back withsubproperty_of: <parent>. The generic form, populator, indexer, and show renderer all read the declaration from the schema.controlled(inlinevalues:or a QAauthority:), auto-linkedurl, andwork_or_url(search readable internal works or collections, or enter an external URL).index_keys:/indexing:) and show display (display:); optionalgroup:clustering andform: { cols:, as: }layout.view: { display: card }), and sit in the form's primary or "Additional fields" section perform: { primary: }.required:; on save the form blocks with one error per compound, and required fields show a*.HYRAX_FLEXIBLE=falseandtrue; an m3 profile validator catches a misconfigured compound on save.Hyrax::CompoundFieldBehavioris the generic, schema-driven member of the same Field Behavior family as the hand-writtenbased_near/redirectsbehaviors; the docs explain when to use each.Screenshots
Work & forms with compound metadata
Collection show page with compound metadata