Embeddable LiveComponent for viewing/editing a single EntityData
record's custom fields — without the admin DataForm LiveView's parent
picker, status control, presence/locking, or multilang tabs.
The host LiveView owns everything outside the fields themselves:
- Loading
record(an%EntityData{}) — expected to arrive with its:entityassociation preloaded. When it is, this component reuses it and never touches the database —:readonlyrender is always DB-free, and so is the initial:editrender as long aslangisnil(a non-nillangmakesFormBuilder.build_fields/3do one cacheableMultilang/settings read; see thelangattribute below). When:entityisn't preloaded, it's instead loaded viaPhoenixKitEntities.get_entity!/2(one query perupdate/2, unless the entity was already resolved for the sameentity_uuidon a previous update) — a correctness fallback, not the intended calling convention. - PubSub — this component neither subscribes nor publishes.
PhoenixKitEntities.EntityData.update/3already broadcasts changes. record.status— autosave never changes it; a draft record stays a draft, a published one stays published.- Guarding who can persist —
handle_event/3only runs the autosave/submit path whenmode == :edit; every other mode silently ignores both events. This isn't just UI polish: aLiveComponent'shandle_event/3is reachable for any event it defines via the component'scid, independent of what the rendered template actually wires up, so a non-:editinstance would otherwise still accept a crafted "autosave"/"submit" push and persist attacker-controlled data into a record the UI is displaying as read-only. render/1mirrors the same fail-closed rule: onlymode == :editrenders the live, autosaving form. Every other value —:readonly, anything else, ormodeomitted entirely (a caller bug) — renders the static readonly view. The alternative (rendering the form whenevermodeisn't literally:readonly) would let a forgottenmodeproduce a form that looks editable whilehandle_event/3's own guard silently drops everything typed into it.- The
mode == :editguard above is a process-level check — it says nothing about whether the record ITSELF has moved on since this particular component instance last read it. Two browser tabs can both hold a live:editinstance of the same record; if one tab submits (moving the record to, say,"published") and the other's still-pending autosave lands afterwards,mode == :editis satisfied in both — the second tab genuinely IS in edit mode, it's just editing a record that has since moved on. Closing that status-transition race — NOT general concurrent-edit/optimistic locking, see thepersist_statusesattribute's own doc for the distinction — is whatpersist_statuses(below) is for: it's optional (nilby default, matching every caller before it existed) precisely because it needs the host LiveView to state which statuses this instance is allowed to write into. - Required-field completeness — incremental autosave is guaranteed
only for entities without required fields; entities with required
fields save all-or-nothing per attempt, by design. Every save runs
the merged data through
PhoenixKitEntities.FormBuilder.validate_data/2on a best-effort basis (for type coercion — number strings, URL normalization — never as a hard gate: seecoerce_or_pass_through/3). However,EntityData.update/3→EntityData.changeset/2runs its own, unconditional required-field check across the entire entity on every single save, independent ofFormBuilderand deliberately left untouched here — relaxing it would also affect the adminDataFormand the public entity form, which both rely on it staying strict. In practice: for an entity with required fields, an autosave attempt while any required field is still empty logs an error and keeps the previousrecordunpersisted, exactly like any other save rejection; filling in the last required field then persists everything typed before it in one shot (thanks to merging overrecord.data). Entities with no required fields autosave incrementally with no such gap. Checking whether a record is "complete enough" to submit is the parent's responsibility either way.
Usage
<.live_component
module={PhoenixKitEntities.Components.LiveDataForm}
id={"survey-" <> record.uuid}
record={record}
mode={:edit}
lang="et"
actor={@current_user}
submit_label={gettext("Kinnitan")}
/>Attributes
record(required) — the%PhoenixKitEntities.EntityData{}being displayed or edited. Preload its:entityassociation before passing it in — that's what keeps this component DB-free to render; see "The host LiveView owns" above.mode(required) —:editrenders a live, autosaving form. Any other value renders static "label: value" output with no inputs; this includes:readonly(the intended non-editing value) but also a caller bug that omitsmodealtogether — the component fails closed to the safe, non-interactive view rather than rendering an editable-looking form that silently drops every edit.lang(optional, defaults tonil— safe to omit entirely) — locale passed straight through toPhoenixKitEntities.FormBuilder.build_fields/3aslang_code(and to entity loading, when the entity isn't already preloaded). In:editmode this drivesFormBuilder's own field-level "translations" resolution (labels + option text; seeFormBuilder's moduledoc for the display-only contract). The static:readonlyview resolves the samefield["translations"]map directly viaFormBuilder.translated_label/2/translated_option_label/3— a stored value (e.g."Must") always renders its translation forlang(e.g. "Чёрный"), falling back to the original label/option text wheneverlangisnilor no translation entry exists. Either way, the value actually persisted inrecord.datais never touched. This component is one of the surfaces that DOES resolve field-level"translations"— seeFormBuilder's moduledoc, "Which surfaces resolve this", for the full list of which callers do and don't.actor(optional, defaults tonil— safe to omit entirely) — the acting user (a struct with a:uuidfield) ornil. Threaded through toEntityData.update/3asactor_uuid:for activity logging.submit_label(optional, defaults tonil— safe to omit entirely) —nilhides the submit button entirely; any other string renders it as the button's text.persist_statuses(optional, defaults tonil— safe to omit entirely) — a list of status strings (e.g.["draft"]), threaded through toEntityData.update/3asrequire_status:on both the autosave and submit persist paths. Recommended for any host LiveView that can have more than one live:editinstance of the same record open at once (e.g. two browser tabs/sessions) — see the cross-session race note above.nil(the default) reproduces the exact pre-persist_statusesbehavior: no status check, save proceeds regardless of the record's current status. When set, a save whose freshly-read record status has moved outside the list fails exactly like any other rejected save (see "Messages sent to the parent" below) — logged atdebug(noterror; the guard doing its job isn't a failure), previousrecordkept, no:saved/:submittedmessage sent. Be precise about what this guards: it's a status-transition check, not optimistic locking ondata. Two sessions both legitimately editing the same record while its status stays withinpersist_statuses(e.g. both still "draft") are NOT protected from each other — whichever save lands last still wins, silently overwriting the other's edits. Closing that race (concurrent edits within the same allowed status) is out of scope here; this attribute only stops a save from landing once the record has moved to a status the caller didn't allow.
Messages sent to the parent
{:live_data_form, :saved, updated_record}— after a successful autosave (fired on every change, debounced 500ms).{:live_data_form, :submitted, updated_record}— after the submit button is pressed. Submit persists the paramsphx-submitjust sent (the same merge-over-record.datapath as autosave, so the last edit is never lost to the debounce window) and only sends this message once that save succeeds —updated_recordreflects it. The component never changesstatusitself — the parent decides what "submitted" means for its workflow.
On a failed save (autosave or submit) the component logs the error and
keeps the previous record — it never crashes the LiveView, and on a
failed submit specifically it does not send :submitted (the parent
must not treat unpersisted data as agreed-upon).
Ordering caveat: a debounced autosave that was already in flight when
Submit was pressed can still land afterwards, so the parent may see
{:live_data_form, :saved, _} arrive after
{:live_data_form, :submitted, _} for the same record. Both messages
carry the freshly-persisted record, so this is harmless as long as the
parent treats each message as "here is the current server copy" rather
than assuming :submitted is always the last word — read the record's
own status/fields rather than inferring lifecycle order from message
arrival, the way the host app (andi) does. This is a single
component instance racing its own two persist paths — distinct from
the cross-session race (a second tab's stale :edit instance of the
same record) that persist_statuses guards against; the two are
independent and both worth being aware of.