Skip to content

Implement a Cocina description editor - #160

Draft
edsu wants to merge 3 commits into
mainfrom
cocina-editor
Draft

Implement a Cocina description editor#160
edsu wants to merge 3 commits into
mainfrom
cocina-editor

Conversation

@edsu

@edsu edsu commented May 11, 2026

Copy link
Copy Markdown
Contributor

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:

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:

"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:

{"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
  • 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.rbresource :description nested under resources :objects
  • Policy: app/policies/object_policy.rbedit_description?

@edsu
edsu force-pushed the cocina-editor branch 3 times, most recently from 1e2e228 to 4725f56 Compare May 19, 2026 19:14
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?`
edsu and others added 2 commits June 25, 2026 11:53
- 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant