feat(ui): schema-driven resource editor — a real Guided form (#264)#277
Open
angela-helios wants to merge 11 commits into
Open
feat(ui): schema-driven resource editor — a real Guided form (#264)#277angela-helios wants to merge 11 commits into
angela-helios wants to merge 11 commits into
Conversation
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.
…4-resource-editor
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).
Contributor
Author
Update: JSON + guided form in parallel, and the JSON is now foldablePer 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.
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. |
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.


Closes #264. Prototype, as the issue asked (design in #265) — built and driven end-to-end in a real browser.
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:
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.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 onPatient.name[0](the "extension on a name" Steve called crazy), offering the name's elements and an Extension-URL field:Type a URL, and the new extension offers all 51
value[x]arms: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
Extensiontype, 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:
[x]-artifact and choice-declarer traps — live once, in Rust, tested (17 unit tests against the real R4 pack). The projection is in the validator crate (helios_fhir_validator::editor), not the UI, because Compartment Editor UI: CRUD for CompartmentDefinitions + a compartment route tester #237 and SearchParameter viewer & editor UI: Resource Filter rail, faceted browse, CRUD with registry-aware validation #238 want the same "what can I add here?" query._birthDatesibling of an extended primitive round-trips here — the quiet data-loss bug every editor surveyed for docs(ui): research & design for a schema-driven FHIR resource editor (#264) #265 has. There is an HTTP test that asserts exactly this.Two safety touches no surveyed editor has
modifierExtensionis flagged — an unrecognised one means "do not process this resource", and no editor surveyed says so.What is deliberately not here (prototype scope, per the issue)
extension-definitions.jsonis not in the pack yet — see docs(ui): research & design for a schema-driven FHIR resource editor (#264) #265 §5). The ad-hoc path works today; the profiled one lights up when that lands.mustSupportemphasis or field labels — the IR carries neither (docs(ui): research & design for a schema-driven FHIR resource editor (#264) #265 §8). Field names are shown as-is.Verification
cargo fmt,clippyclean. 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 avalue[x], anchors a structural error — no JS errors.