Skip to content

feat(ui): schema-driven resource editor — a real Guided form (#264)#277

Open
angela-helios wants to merge 11 commits into
feat/fhir-validatorfrom
feat/264-resource-editor
Open

feat(ui): schema-driven resource editor — a real Guided form (#264)#277
angela-helios wants to merge 11 commits into
feat/fhir-validatorfrom
feat/264-resource-editor

Conversation

@angela-helios

@angela-helios angela-helios commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Closes #264. Prototype, as the issue asked (design in #265) — built and driven end-to-end in a real browser.

Stacked on feat/fhir-validator (#232). The editor consumes that PR's FHIR Schema IR. It cannot merge before #232 does, and it will need a re-target once #232 lands.

What this is

Brett's "Resources - Edit" frame gave the shell — the Guided form / JSON toggle, Save & Delete, the editable tree. What the mock could not do is the thing Steve flagged in his notes:

"if you wanted to create an address? People have multiple addresses. How do you do that? There's no concept here."

He was right about the mock: it is eight hand-picked fields. This is a Guided form that is generated from the resource's schema, so it can add a second address, an extension on a name, a value[x] the designer never thought of — anything the schema allows, at any depth.

loaded

The thing no other editor does

An arbitrary extension, by URL, at an arbitrary node, on a resource with no profile — with the full value[x] type pick. Here is the "add" panel open on Patient.name[0] (the "extension on a name" Steve called crazy), offering the name's elements and an Extension-URL field:

addable on a name

Type a URL, and the new extension offers all 51 value[x] arms:

value x pick

This is the union the whole field is missing (from the #265 survey): Medplum needs a profile to touch an extension; Simplifier, Aidbox, HAPI send you to raw JSON. We do it from the base Extension type, so it needs no profile and works nested.

How it is built

The document is the state. It rides in a hidden field; every structural mutation posts the whole resource back, the server applies it, re-validates, and re-renders. No client model, no session. Three things fall out of that:

error anchored

Two safety touches no surveyed editor has

  • A modifierExtension is flagged — an unrecognised one means "do not process this resource", and no editor surveyed says so.
  • A key the schema does not know is shown and kept, marked "not in schema", rather than silently dropped.

What is deliberately not here (prototype scope, per the issue)

Verification

cargo fmt, clippy clean. 17 projection unit tests + 7 HTTP round-trip tests + locale parity (en/es/de). Driven in headless Edge: loads Donald Duck, adds an ad-hoc extension to his name, picks a value[x], anchors a structural error — no JS errors.

Implements the savedQueries user-settings convention (#234) end to end
on the opaque-document approach:

- Convention documented in helios-persistence's user_settings module:
  savedQueries.<ResourceType>.<id> entries keyed by query id, so an RFC
  7386 merge patch can create/rename/touch/delete one entry without
  clobbering siblings (merge-patch replaces arrays wholesale).
- REST: the settings handlers now enforce structural bounds with clear
  errors - a 256 KiB whole-document cap (413) and 100 saved queries per
  resource type (422). PATCH validates the post-merge document: it
  merges against the version it read, pins the write to that version,
  and absorbs benign concurrent-writer races with a bounded retry when
  the caller sent no If-Match of their own.
- UI: new /ui/queries page. Server-rendered shell; the list is hydrated
  client-side from /_user/settings by saved-queries.js, which - unlike
  theme.js's last-write-wins scalar - runs the full conditional-request
  cycle (capture ETag, If-Match on PATCH, re-read and retry on 412).
  Grouped by resource type, sorted by lastAccessedAt descending with
  never-run entries falling back to createdAt; add/rename/delete plus
  Run, which opens the FHIR search in a new tab and bumps
  lastAccessedAt/accessCount. Sidebar gains the entry and an
  active-page mechanism for aria-current; strings added to en/es/de.

Verified end to end against a running hfs: page and asset serve, save,
touch preserving siblings, stale If-Match 412, array-shape 422, delete
via null patch, and the run URL returning the search bundle.
Implements the read halves of #238 and #237 in helios-ui, following the
docs/237-238-mockups designs on the existing design system.

/ui/search-parameters (#238): read-only viewer over the same snapshot
the storage backends seed their registries from (embedded fallback +
the spec bundle in HFS_DATA_DIR, first canonical URL wins). Resource
Filter rail with live counts and a Recently-used group (localStorage
enhancement; everything else is links + GET forms, so the page works
without JavaScript), type/source facet rows scoped to the current base,
pagination instead of a render cap, and a detail panel with (base,
code) slot chips per the #242 precedence semantics - cross-source
shadows are informational overrides, same-source collisions are the
blocking DuplicateCode conflict - plus empty-expression / missing-
target warnings and the choice-type rewrite note. A missing spec
bundle renders a visible warning instead of under-reporting silently.
Write paths stay off pending #235, and the page says so.

/ui/compartments (#237): definition rail with member counts, read-only
Definition tab, Members tab with member/excluded chips and per-type
linking params, and the tester. Everything membership-shaped resolves
through helios_fhir::compartment_params() - the codegen'd table the
REST compartment handler consults - so the tester's member / 404 +
OperationOutcome / *-fan-out / {def} answers are the answers the API
gives. Definition metadata comes from the spec CompartmentDefinition
JSONs vendored under crates/ui/data/ (include_str! across crates would
break cargo package); a parity test asserts every (compartment, type)
slot matches the codegen table for every enabled version, so the
vendored copies cannot drift silently. The questionnaire.json oddity
(no code, zero resources) is not vendored, per issue open question 4.

helios-ui grows R4/R4B/R5/R6 features forwarded from hfs (helios-ui?/…)
so the version pill covers exactly the versions the server is built
with; mount() takes the data dir. Semantic --ok/--warn/--danger tokens
join app.css for both themes, held off the accent hue. Sidebar's
Compartments entry goes live and Search Parameters joins the Server
section; strings in en/es/de.

Verified end to end against a running hfs: both pages serve the real
R4 registry (1377 definitions; Patient 66/145 members), the tester
round-trips member, 404, and fan-out cases, and the ES locale renders.
…oaming recent searches

Reworks /ui/queries to the console hi-fi design: the primary control is a
featured, editable GET URL in the builder card - Run opens the search in a
new tab and records it, Save keeps it under the name given next to it. The
old three-field form (type / name / query) is gone; the resource type now
comes from the URL path, which is what the hi-fi's Search Builder does and
removes the one field users had to duplicate by hand.

Every run - from the builder or from a saved row - is recorded to a new
recentSearches key in the per-user settings document, so recents roam
across devices like everything else on this page. The Recent dropdown (a
details disclosure, same primitive as the tenant selector) lists them
newest-first with a same-day time or date stamp, loads one back into the
builder on click, and deletes inline. Unlike savedQueries, the list is
deliberately an array: it is a small bounded cache (10 entries, deduped by
query) rewritten wholesale on each run, so RFC 7386's replace-the-array
semantics are exactly right - the convention is documented alongside
savedQueries in helios-persistence's user_settings module. Writes reuse
the existing ETag/If-Match/412-retry cycle, now factored so entry-scoped
and whole-key patches share it.

Strings added to en/es/de; the shell tests cover the builder controls and
the recentSearches wiring. Verified against a running hfs: PATCHing
recentSearches round-trips the REST validation, and the page hydrates the
dropdown, the timestamps, and the saved list from the document.
…es) into feat/237-238-registry-compartment-ui

# Conflicts:
#	crates/ui/assets/app.css
#	locales/de/main.ftl
#	locales/en/main.ftl
#	locales/es/main.ftl
…results

Grows the queries page's Search Builder to the rest of the hi-fi console
design, on top of the featured GET URL:

- Visual rows in two-way sync with the URL: Conditions (parameter /
  modifier / value, add and remove), Includes (_include/_revinclude with
  :iterate), and Result controls (_count/_sort/_total/_summary/_elements).
  Colon modifiers rejoin onto the key, comparator prefixes (ge, le, ...)
  onto the value, matching how FHIR search actually spells them. Editing
  the URL re-derives the rows; editing a row rewrites the URL.
- Parameter suggestions come from the server's SearchParameter registry:
  GET /ui/queries/params?type=X returns a datalist fragment (options
  labeled with the parameter type) rendered from the same snapshot the
  SearchParameter viewer reads, swapped per resource type as the URL's
  type changes. Server-rendered hypermedia, not a UI-facing JSON API.
- Run now executes in-page against the FHIR REST API itself and renders
  the Bundle: total (with an included-resources count when _include is in
  play), a table whose columns honor _elements with compact formatting
  for common shapes (HumanName, CodeableConcept, Reference, Quantity),
  row links to the resource, OperationOutcome diagnostics on errors, and
  paging over Bundle.link. Explicit runs still record to recentSearches;
  paging does not. An Open-in-new-tab link keeps the old behavior a click
  away, and /ui/queries?url=... deep-links straight into a loaded builder
  and executed search.
- Global [hidden] { display: none !important } so script-toggled regions
  (pager buttons, builder sections, the rail's recents group) cannot be
  un-hidden by their class display values.

Verified against a running hfs with seeded Patients: the deep link
renders condition and control rows from the URL, the datalist swaps to
the Patient catalog (birthdate et al.), and the search renders '2
results' with name/gender/birthDate columns and formatted HumanNames.
Adds the hi-fi Search screen's remaining FHIR-applicable piece: a left
rail listing every resource type of the version, sourced from the spec
CompartmentDefinitions already vendored for the compartment viewer (each
enumerates the full set - 145 in R4), so no new dependency. Picking a
type re-keys the builder to GET /{Type} and runs the browse; a filter
box narrows the list client-side; the active type tracks the URL.

Counts hydrate client-side through the standard count-only search,
_summary=count, a few requests at a time so 145 types don't stampede
the server. _total=accurate rides along because this server only
computes Bundle.total when asked explicitly, even under _summary=count
- that gap is worth fixing server-side, at which point the extra
parameter is harmless.

The page goes content-wide with the rail | builder+results layout of
the design. The hi-fi's AI search panel is deliberately not included:
natural-language-to-URL translation is not a FHIR capability and needs
a backend HFS does not have; it stays a follow-up.

Verified against a running hfs with seeded data: counts render per type
(including real zeros), clicking a type browses it in-page, and the
deep-linked builder + results still work.
…un it (#255)

Type "female patients over 65 with a diabetes diagnosis" and get back a FHIR
search query you can read, correct, and run.

POST /$nl-search returns a *query*, not results. The client then executes it
through the ordinary search path, which keeps auth, tenancy, audit, and search
semantics on the one code path we already trust, and makes the query reviewable
before it runs. The translator has no privileges of its own and never touches
storage.

Nothing the model says is trusted:

- Its output shape is a forced, strict tool call, so a jailbroken model has
  nowhere to put free text.
- Every generated query is parsed and validated against the SearchParameter
  registry before it is returned. Unknown resource type, unknown parameter,
  smuggled payload: it fails closed with a friendly error and never executes.
- The checked-in system prompt (nl_search_prompt.md) scopes the model to
  translating search intent, treats the input as data rather than instructions,
  refuses anything that is not a search over this server's data, and — when one
  input packs a search together with something else — translates only the search
  and says the rest was ignored.

The prompt is grounded in the server's real searchable vocabulary from the
registry, so it never advertises a parameter this server cannot execute.

Protecting the operator's key: a sliding-window rate limit plus a daily ceiling
and an input length cap, keyed per authenticated user within a tenant, falling
back to the peer address when auth is off (new PeerIp extractor; the binary now
serves with ConnectInfo). Over the limit is 429 + OperationOutcome.

Three states, and off means gone:

- HFS_NL_SEARCH_ENABLED=false — no endpoint, no page, no mention anywhere.
- enabled, no API key — the Search page advertises the feature, names the env
  vars, and links the how-to; the visual builder still works.
- enabled + HFS_NL_SEARCH_API_KEY — working.

UI: the Search page from the Figma "Search V1.0" frame — a segmented Natural
language / Visual builder switch over one editable QUERY strip, with the
explanation and any caveats under it. Both modes write the same query and
neither runs anything; Run is always a separate click. The builder, results and
message partials are now shared with the Saved Queries page rather than
duplicated.

Docs: book/src/components/natural-language-search.md covers what is sent to the
model (the user's text and the server's search-parameter names — never patient
data), how to get and set a key, cost, the rate limiter, and how to switch it
off for good.
… back

Findings from the first run against a real model, which the mock could not have
surfaced:

The model has no clock. Asked for "female patients over 65" it computed the
cutoff against a date it invented — 17 months in the past — and returned
birthdate=le1960-02-14 with a confident caveat saying so. Half of what a
clinician asks for is relative ("over 65", "in the last year"), so the prompt
now carries today's date and is told never to guess it. The same request now
produces birthdate=le1961-07-14.

A malformed answer was being echoed into the UI. One run leaked the model's own
tool-call markup into resource_type, and the error message interpolated it
straight into text that lands in a browser. Untrusted output never leaves the
server now: the caller gets a fixed sentence, the raw value goes to the log,
and the whole response is logged at debug — which is where diagnosing this
belongs.

That leak was transient and the validation layer caught it (the search never
ran), but a bad sample should not be the user's problem, so an unusable
translation is now retried once. Two provider calls, hard maximum.

Also drops "strict": true from the tool definition — an OpenAI field the
Anthropic Messages API does not have, and was ignoring.
Prototype, built on top of the FHIR Schema IR from #232 (this branch is stacked
on feat/fhir-validator).

Brett's "Resources - Edit" frame gave the shell: the Guided form / JSON toggle,
Save & Delete, the editable tree. What Brett could not draw — and what Steve
flagged in his notes ("if you wanted to create an address, people have multiple
addresses, how do you do that? there's no concept here") — is a form that can
edit FHIR rather than a fixed list of eight hand-picked fields. This is that
form, and the difference is that it is generated from the resource's schema.

The projection lives in the validator crate (helios_fhir_validator::editor), not
the UI, because #237 and #238 want the same thing: given a cursor in a resource,
what may be added here? It answers from the public IR — spec order, cardinality
already spent excluded, value[x] offered as a type pick, the choice-declarer and
[x]-artifact traps handled — with 17 unit tests against the real R4 pack.

The architecture is whole-document round-trip over htmx: the document is the
state, it rides in a hidden field, and every structural mutation posts it back
for the server to apply, re-validate (the pure ~300µs sync pass, on every edit),
and re-render. No client model, no session. The editor's hard rules live once,
in Rust, where they are tested; validation errors come back already anchored to
their node by an exact dotted path, not the fuzzy string-matching other editors
resort to; and — because nothing is projected through a form — whatever we do
not render, we do not destroy. The _birthDate sibling of an extended primitive
survives a round-trip here and does not survive one in any editor surveyed for
the design doc.

Extensions are ordinary elements, rendered inline and tinted, not a panel and
not a JSON escape hatch. Any node that accepts an extension accepts any
extension: type a URL and the base Extension type gives url, a recursive
extension, and all 51 value[x] arms — so you can attach an arbitrary extension
at any depth, on a name, nested, on a resource with no profile. That is the
union the whole field is missing: Medplum needs a profile, everyone else sends
you to raw JSON. Verified end to end in a real browser.

Two safety touches no surveyed editor has: a modifierExtension is flagged
(an unrecognised one means "do not process this resource"), and a key the schema
does not know is shown and kept rather than silently dropped.

UI strings in en/es/de (parity enforced). 7 HTTP round-trip tests. The nav's
Resource Editor entry is now a real link.
Reworks the editor to Brett's Resource Editor layout: the JSON and the guided
form sit side by side over one document — not a toggle — with a version rail
alongside, all on one screen.

The JSON is now a real code view, not a textarea: line numbers down the gutter
and a fold arrow on every object and array, so a large resource collapses to its
shape. A textarea can do neither, so the view is rendered server-side
(json_view module) into line records the template lays out and a few lines of JS
fold — collapsing a container hides its descendants and its closing line and
shows a { … } / [ N ] summary. Collapse-all / expand-all, and syntax
highlighting for keys / strings / numbers / booleans. Raw editing is one "Edit
raw" toggle away, and applying it re-parses and re-renders the form.

The version rail is hydrated from the ordinary _history API (etag/lastModified,
with resource.meta as a fallback), newest first, each tagged with the
interaction that produced it; clicking a version loads it into the editor.

6 unit tests over the JSON view + an HTTP test asserting the parallel,
line-numbered, foldable render. Verified end to end in a real browser: 26 JSON
lines with numbers, 8 fold arrows, fold hides the right lines and shows the
summary, collapse-all works, no JS errors. UI strings in en/es/de (parity).
@angela-helios

Copy link
Copy Markdown
Contributor Author

Update: JSON + guided form in parallel, and the JSON is now foldable

Per the feedback that Brett's Resource Editor shows the JSON and the guided form side by side (not a toggle), with line numbers and expand/collapse arrows on objects — the editor now does exactly that.

parallel

  • JSON and the guided form sit side by side over one document, with a version rail to the right — three panels on one screen, matching Brett's frame. The old Guided form / JSON toggle is gone.
  • The JSON is a real code view: line numbers in the gutter, a fold arrow on every object and array, and syntax highlighting (keys / strings / numbers / bools). A textarea can do none of that, so it is rendered server-side (json_view module, 6 unit tests) into line records a few lines of JS fold. Collapse all / Expand all in the header; Edit raw is one toggle away and re-renders the form on apply.
  • Collapse all folds each container to its { … } / [ N ] summary:

collapsed

  • The version rail is hydrated from the ordinary _history API (etag / lastModified, resource.meta fallback); clicking a version loads it into the editor.

Verified end to end in a real browser: 26 JSON lines with numbers, 8 fold arrows, folding hides the right lines and shows the summary, collapse-all works, no JS errors. An HTTP test asserts the parallel line-numbered foldable render; locale parity across en/es/de. fmt/clippy clean.

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