Skip to content

feat(ui): link the editor's guided form and JSON view — live sync, cross-highlight, type-honest edits - #573

Merged
smunini merged 9 commits into
mainfrom
feat/editor-form-json-sync
Aug 19, 2026
Merged

feat(ui): link the editor's guided form and JSON view — live sync, cross-highlight, type-honest edits#573
smunini merged 9 commits into
mainfrom
feat/editor-form-json-sync

Conversation

@angela-helios

Copy link
Copy Markdown
Contributor

Manual-review feedback on the resource editor, turned into a feature: the guided form and the JSON view now behave as two views of one document instead of two documents that meet on save.

Stacked on #572 (base is fix/543-stylesheet-unification — it touches the same templates and stylesheet; GitHub will retarget to main when #572 merges).

The link

  • Row → JSON: hovering or focusing a guided-form row lights every JSON line of that node (its whole subtree), keeping the first in the pane's own viewport. Server-side, every JSON line now carries the node's dotted path (data-jpath) — the same spelling the rows key on, so the match is string equality.
  • JSON → row: hovering a line lights the row that edits it (nearest ancestor with a row, for leaves inside a complex type); clicking a line jumps to the row and focuses its input.
  • Raw mode, both directions: with "Edit raw" open, valid JSON re-renders the guided form live — only the form pane and the hidden document field are swapped, so the textarea and caret are never touched, and invalid JSON simply waits. The caret lights the row of the node it sits in (structure-only scan, string- and escape-aware, tolerant of half-typed documents). Hovering a row marks the node's character range inside the textarea through a metrics-identical mirror <pre> (pointer-events: none, invisible except the <mark>). And a guided edit no longer kicks the user back to the fold view: raw mode survives the re-render with the refreshed document in the textarea.
  • The delegated script (editor-sync.js) drives the standalone page and the Resources modal alike; without JS the editor is simply un-linked.

Fixes the same review shook out

  • Row hierarchy: the element name leads the row (it used to render small and mono while the description got the label styling); the description sits on its own line below, muted, free to wrap — and the input keeps a 180px floor with the head wrapping instead of squeezing it invisible.
  • Type-honest value coercion (set_value): the editor guessed JSON types from the shape of the text, so a year-precision birthDate of 1974 — legal FHIR — round-tripped as a JSON number and failed validation with "expected date, got number", and an identifier.value of 12345 was silently retyped. Coercion now follows the declared type: only booleans and the numeric primitives leave string-land (integer64 stays a string, as FHIR serializes it); unknown keys keep the old shape-based guess. Decimal entry also works now — it previously fell through the integer-only parse.
  • The results table catches up: saving or deleting in the Resources modal announces hfs:data-changed; the table re-runs the last search (without recording a new recent) and re-hydrates the affected type's rail count — not all 145. Delete loses its location.reload().
  • Leaving raw mode regressed once raw survived re-renders (the state capture ran while the pane was still open and faithfully restored it) — caught by the new spec's first run; both editor wirings now close the pane before that round trip.

Tests

  • editor-sync.spec.ts: seven browser-level specs — hover both directions, click-to-focus, live raw→form with raw surviving a guided edit, caret follow, the mirror marking without ever taking a click, the row hierarchy, and the modal save refreshing the table behind it.
  • New validator unit tests for declared-type coercion (year-only date, numeric-looking identifier, unknown key).
  • Full local suite, hermetic self-boot: 130 passed (chromium + nojs + both auth legs, axe both themes).

…riptions

The guided form led with the element's short description and demoted the name
to small mono type — backwards for scanning. The name is the label now; the
description rides second, muted and ellipsized, full text in the tooltip.

The two panes also point at each other. Every JSON line now carries the dotted
document path of its node (the same spelling the rows key on), so hovering or
focusing a form row highlights that node's lines in the JSON, hovering a JSON
line highlights the row that edits it, and clicking a JSON line jumps to the
row and focuses its input. Delegated document-level script (editor-sync.js),
so the standalone page and the Resources modal both get it; without JS the
editor is simply un-linked.
While "Edit raw" is open the JSON pane is one textarea, so there are no
lines to cross-highlight — instead the caret drives the form. A structure-only
scan of the text up to the caret (string- and escape-aware, so a half-typed
document still resolves) yields the dotted path of the node the caret sits in;
that row lights up and is revealed in the tree. A caret inside a key string
binds the key by looking ahead for the colon, so typing a new field name lands
on that entry's row, not the container's.
Typing valid JSON in the raw textarea now re-renders the guided form as you
go: the document round-trips to /ui/editor/render and only the form pane, the
hidden document field, and the binding datalists are swapped — the textarea
and its caret are never touched. Invalid JSON just waits; the form always
shows the last parseable state.

The other direction fell out of raw mode surviving re-renders: a guided edit
already round-trips the textarea's document with the mutation applied, and
capture/restore in both editor wirings now keeps the raw pane open, so the
refreshed textarea shows the change instead of snapping back to the fold
view.
With raw mode open there are no fold-view lines to light, so hovering or
focusing a guided-form row found nothing to point at. A mirror <pre> now sits
behind the textarea with identical metrics; its text is invisible and only
the <mark> around the hovered node's character range shows through (the
textarea's own background goes transparent while mirrored). The range comes
from the same structure-only scan as the caret follow, entering and leaving
the target's subtree — string-aware, tolerant of half-typed documents, and
careful not to drag an ancestor's closing bracket into the range. The mark
also pulls the textarea's scroll to keep the node in view, and every
keystroke clears the mirror so stale offsets never mark the wrong text.
…rough

The mirror positioned itself against the viewport — a full-width input-colored
slab over the page as soon as a guided row got focus with raw mode open. The
#543 dead-class sweep had removed class="editor-json__raw" from the raw pane
(it matched no rule at the time), so the new .editor-json__raw { position:
relative } anchor matched nothing and the absolute mirror escaped to the
body. The class is back — it is load-bearing now — and the mirror is also
pointer-events: none, so an aria-hidden overlay can never eat clicks again,
wherever it ends up.
Name, badges, and the input stay on the head line — the input now keeps a
180px floor and the head wraps instead of squeezing it invisible — and the
element's short description moves below, muted, free to wrap. Long
descriptions used to eat the row and push the field out of sight.
…r shape

The editor's set operation guessed the JSON type from the text: anything that
parsed as an integer became a JSON number. A year-precision birthDate of 1974
— legal FHIR — round-tripped as a number and failed validation with
"expected date, got number"; an identifier value of 12345 was silently
retyped the same way.

set_value now asks the schema: booleans and the numeric primitives (integer,
positiveInt, unsignedInt, decimal — not integer64, which FHIR serializes as a
string) leave string-land, everything else stays a string no matter how
numeric it looks. Elements the schema does not know keep the old shape-based
guess so unknown keys still round-trip their JSON types. Decimal entry also
works now — it previously fell through the integer-only parse and was stored
as a string.
…data

Saving in the Resources modal left the table underneath showing the world as
it was before the save; only a manual page refresh caught it up (and delete
did a full location.reload()). The two scripts now meet over one event:
resources.js announces hfs:data-changed after a successful save or delete
(naming the affected type), and saved-queries.js re-runs the last search —
without recording a new recent — and re-hydrates that one type's rail count
rather than stampeding all 145. Delete loses its full-page reload and keeps
the rail selection and scroll where the user left them.
editor-sync.spec.ts drives the whole contract in a real browser: row hover
lights the JSON lines and back, clicking a line focuses the row's input, valid
raw JSON re-renders the form without leaving raw mode and a guided edit
refreshes the textarea in place, the caret lights the row of the node it sits
in, the mirror marks the hovered node's text without ever taking a click, the
element name leads the row with the description under it, and a save in the
Resources modal refreshes the results table behind it.

The suite's first run caught a real regression: with raw mode surviving
re-renders, the exit toggle could no longer close it — the state capture ran
while the pane was still open and faithfully restored it. Both editor wirings
now close the pane before that round trip, so the one re-render that means
"the user left raw mode" reads that way.
@angela-helios
angela-helios requested a review from smunini August 19, 2026 07:22
Base automatically changed from fix/543-stylesheet-unification to main August 19, 2026 17:34
@smunini
smunini merged commit 9636888 into main Aug 19, 2026
3 checks passed
@smunini
smunini deleted the feat/editor-form-json-sync branch August 19, 2026 17:35
smunini added a commit that referenced this pull request Aug 19, 2026
Three `set_value` calls in the editor tests exceed the line limit and
`cargo fmt --check` rejects them, failing the Linting job. Arrived on
main with #573; purely mechanical reformat, no behavior change.

Claude-Session: https://claude.ai/code/session_01ArLi5r8NFZ2vJJ7uJvwZWA
smunini added a commit that referenced this pull request Aug 19, 2026
Two call sites exceeded rustfmt's default fn_call_width (60) and were left
on one line, failing `cargo fmt --all -- --check`:

* crates/fhir-validator/src/editor.rs — arrived on main with #573 and is
  already breaking main's Linting job at 9636888; the merge inherited it.
* crates/ui/tests/subscriptions_http.rs — predates the merge on this branch.

Pure formatting; no behavior change.

Claude-Session: https://claude.ai/code/session_0197k7pHRnRPd2TaDjibNSDJ
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.

2 participants