Implement a Cocina description editor - #160
Draft
edsu wants to merge 3 commits into
Draft
Conversation
edsu
force-pushed
the
cocina-editor
branch
3 times, most recently
from
May 19, 2026 19:14
1e2e228 to
4725f56
Compare
This implementation was written by Claude Code using design artifacts in https://github.com/sul-dlss-labs/cocina-editor-design/ What follows is a summary of the implementation written by Claude after implementing the design and several rounds of edits that are preserved in the chat transcript alongside the design documents. --- This document records what was built, key decisions made during implementation, and where the final result diverged from the original `design.md`. All three phases from the design plan were completed: - **Titles** — simple value + type, and structured titles (main title, subtitle, part number, part name) each with an editable type select per part - **Notes** — value + type dropdown - **Language** — code + label - **Contributors** — simple name or structured name + life dates, type, primary toggle, role field - **Subjects** — flat `{value, type, source}` and flat LCSH compound headings (`structuredValue` with editable part types) - **Events** — date, event type, publisher, place - **Form** — fully implemented (not just extent as originally scoped); value + type + source - **Access** — physical location and access contact - **Related resources** — title, relationship type, URL The route is `GET/PATCH /objects/:object_druid/description` (nested under the existing `:objects` resource using its `param: :druid` convention, not `/items` as sketched in the design). The design's core approach (curated form for common shapes, raw JSON textarea escape hatch for everything else) proved sound and was applied consistently across all six field types that have polymorphic shapes. The boundary condition — "flat `structuredValue` → curated, nested `structuredValue` → JSON" — held up against all 11 sample objects. The JSON escape hatch was further improved with: - **Server-side pretty-printing** via `JSON.pretty_generate` so complex JSON is readable at a glance - **Info callouts** explaining specifically why a given item fell back to JSON (e.g. "This event contains a structured or parallel date range and must be edited as JSON"), computed from the same conditions that triggered the fallback - **Auto-formatting** on blur via a `json_formatter` Stimulus controller that re-indents valid JSON and flags invalid JSON with Bootstrap's `is-invalid` style - **"Show JSON" toggle** on curated items so editors can inspect the underlying data without switching to raw editing For fields where the form only exposes a subset of properties (events, contributors, structured titles), the full original JSON is stored in a `_original` hidden field and used as the base when rebuilding the object on save. This preserves fields not yet surfaced by the editor (MARC country codes, date encoding attributes, role URIs, etc.) without the editor needing to know about them. Rather than relying on Stimulus to serialize the form to JSON (as the design sketched), the controller rebuilds each section from standard Rails form params via a set of `build_title`, `build_contributor`, `build_subject`, `build_event`, `build_form`, and `build_related_resource` methods. These merge submitted fields back into the existing description, preserving any fields not exposed by the editor. In real SDR data, the event `type` is often stored on `date[0].type` rather than on the event itself. The display resolved this with: ```ruby event_type = event[:type].presence || event.dig(:date, 0, :type) ``` This matches the logic in the `cocina_display` gem's `event.rb`. Publication events frequently carry two location entries — a human-readable place name and a MARC country code: ```json "location": [{"value": "New York"}, {"code": "nyu"}] ``` The original implementation replaced all locations with a single entry. The fix identifies the value-bearing entry by index and updates only that one, leaving the code entry untouched. Publisher names in events follow the pattern: ```json {"contributor": [{"name": [{"value": "Munn & Co."}], "type": "organization", "role": [{"value": "publisher", ...}]}]} ``` This is common enough that it was promoted to a curated `publisher` field in the event form rather than left to the JSON escape hatch. The design anticipated raw JSON for structured values. In practice, all `structuredValue` arrays in the sample data are flat (depth 1), so a curated form was built for both: - **Titles**: each `structuredValue` part rendered as a value input + type select (main title, subtitle, part number, part name, nonsorting characters), with a separate title-level type select (alternative, uniform, translated, etc.) - **Subjects**: flat LCSH compound headings rendered as a list of value + type select rows, preserving the full original via `_struct_original` Deeply nested `structuredValue` (depth > 1) still falls through to the JSON textarea. | Design | Actual | |---|---| | Field-level error injection via Turbo Streams | Flash warnings at page level; Turbo Stream error injection deferred | | ETag / lock field for concurrent edit protection | Not implemented; deferred | | Stimulus serializes form to JSON on submit | Standard Rails form params; controller rebuilds description | | `form` scoped to extent only | Full form field implemented (all types) | | Route `/items/:druid/description` | `/objects/:object_druid/description` (matches existing resource) | | Inline error paths from `Cocina::Models::ValidationError` | `CocinaSupport.validate` returns a `Dry::Monads::Failure` string; shown as flash | - **Controller**: `app/controllers/descriptions_controller.rb` - **Edit view**: `app/views/descriptions/edit.html.erb` - **Field partials**: `app/views/descriptions/_*_fields.html.erb` - **JSON toggle partial**: `app/views/descriptions/_json_toggle.html.erb` - **Stimulus controllers**: `description_editor_controller.js`, `json_formatter_controller.js` - **Request spec**: `spec/requests/descriptions_spec.rb` - **Route**: `config/routes.rb` — `resource :description` nested under `resources :objects` - **Policy**: `app/policies/object_policy.rb` — `edit_description?`
- Guard safe_parse_json against non-hash JSON (e.g. "null"), and make build_contributor consistent so a newly added item whose _original serializes to "null" is built rather than crashing or being dropped. - Normalize the subject _struct_original hidden field to .to_h.to_json, matching the other fields, so it can never emit a bare "null". - Wrap the edit form in a .container so it keeps the page margin when it re-renders as a full page after a save error (ignored on frame load). - Stop restoring @eXisting when a field's section is submitted empty, so deleting the last item of a field (form, subject, language, physical location, etc.) actually removes it. - Capture the object returned by VersionService.open so the update sends the newly opened version and lock instead of a stale version, fixing the "Version N does not match head version N+1" 502 on a second edit. - Open a version only at save time, not when the edit form is displayed, to avoid dangling versions from loading the form without saving. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The description editor offered a subject type of "temporal", but the
canonical cocina vocabulary (description_types.yml) uses "time". Selecting
it produced a description that failed DescriptionTypesVisitorValidator
("Unrecognized types in description") on save. The stored value is now
"time"; the display label remains "Temporal".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This implementation was written by Claude Code using design artifacts in https://github.com/sul-dlss-labs/cocina-editor-design/
What follows is a summary of the implementation written by Claude after implementing the design and several rounds of edits that are preserved in the chat transcript alongside the design documents.
This document records what was built, key decisions made during implementation, and where the final result diverged from the original
design.md.All three phases from the design plan were completed:
{value, type, source}and flat LCSH compound headings (structuredValuewith editable part types)The route is
GET/PATCH /objects/:object_druid/description(nested under the existing:objectsresource using itsparam: :druidconvention, not/itemsas sketched in the design).The design's core approach (curated form for common shapes, raw JSON textarea escape hatch for everything else) proved sound and was applied consistently across all six field types that have polymorphic shapes. The boundary condition — "flat
structuredValue→ curated, nestedstructuredValue→ JSON" — held up against all 11 sample objects.The JSON escape hatch was further improved with:
JSON.pretty_generateso complex JSON is readable at a glancejson_formatterStimulus controller that re-indents valid JSON and flags invalid JSON with Bootstrap'sis-invalidstyleFor fields where the form only exposes a subset of properties (events, contributors, structured titles), the full original JSON is stored in a
_originalhidden field and used as the base when rebuilding the object on save. This preserves fields not yet surfaced by the editor (MARC country codes, date encoding attributes, role URIs, etc.) without the editor needing to know about them.Rather than relying on Stimulus to serialize the form to JSON (as the design sketched), the controller rebuilds each section from standard Rails form params via a set of
build_title,build_contributor,build_subject,build_event,build_form, andbuild_related_resourcemethods. These merge submitted fields back into the existing description, preserving any fields not exposed by the editor.In real SDR data, the event
typeis often stored ondate[0].typerather than on the event itself. The display resolved this with:This matches the logic in the
cocina_displaygem'sevent.rb.Publication events frequently carry two location entries — a human-readable place name and a MARC country code:
The original implementation replaced all locations with a single entry. The fix identifies the value-bearing entry by index and updates only that one, leaving the code entry untouched.
Publisher names in events follow the pattern:
{"contributor": [{"name": [{"value": "Munn & Co."}], "type": "organization", "role": [{"value": "publisher", ...}]}]}This is common enough that it was promoted to a curated
publisherfield in the event form rather than left to the JSON escape hatch.The design anticipated raw JSON for structured values. In practice, all
structuredValuearrays in the sample data are flat (depth 1), so a curated form was built for both:structuredValuepart rendered as a value input + type select (main title, subtitle, part number, part name, nonsorting characters), with a separate title-level type select (alternative, uniform, translated, etc.)_struct_originalDeeply nested
structuredValue(depth > 1) still falls through to the JSON textarea.app/controllers/descriptions_controller.rbapp/views/descriptions/edit.html.erbapp/views/descriptions/_*_fields.html.erbapp/views/descriptions/_json_toggle.html.erbdescription_editor_controller.js,json_formatter_controller.jsspec/requests/descriptions_spec.rbconfig/routes.rb—resource :descriptionnested underresources :objectsapp/policies/object_policy.rb—edit_description?