PhoenixKitEntities.Components.LiveDataForm (PhoenixKitEntities v0.2.8)

Copy Markdown View Source

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 :entity association preloaded. When it is, this component reuses it and never touches the database — :readonly render is always DB-free, and so is the initial :edit render as long as lang is nil (a non-nil lang makes FormBuilder.build_fields/3 do one cacheable Multilang/settings read; see the lang attribute below). When :entity isn't preloaded, it's instead loaded via PhoenixKitEntities.get_entity!/2 (one query per update/2, unless the entity was already resolved for the same entity_uuid on a previous update) — a correctness fallback, not the intended calling convention.
  • PubSub — this component neither subscribes nor publishes. PhoenixKitEntities.EntityData.update/3 already 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/3 only runs the autosave/submit path when mode == :edit; every other mode silently ignores both events. This isn't just UI polish: a LiveComponent's handle_event/3 is reachable for any event it defines via the component's cid, independent of what the rendered template actually wires up, so a non-:edit instance 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/1 mirrors the same fail-closed rule: only mode == :edit renders the live, autosaving form. Every other value — :readonly, anything else, or mode omitted entirely (a caller bug) — renders the static readonly view. The alternative (rendering the form whenever mode isn't literally :readonly) would let a forgotten mode produce a form that looks editable while handle_event/3's own guard silently drops everything typed into it.
  • The mode == :edit guard 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 :edit instance of the same record; if one tab submits (moving the record to, say, "published") and the other's still-pending autosave lands afterwards, mode == :edit is 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 the persist_statuses attribute's own doc for the distinction — is what persist_statuses (below) is for: it's optional (nil by 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/2 on a best-effort basis (for type coercion — number strings, URL normalization — never as a hard gate: see coerce_or_pass_through/3). However, EntityData.update/3EntityData.changeset/2 runs its own, unconditional required-field check across the entire entity on every single save, independent of FormBuilder and deliberately left untouched here — relaxing it would also affect the admin DataForm and 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 previous record unpersisted, exactly like any other save rejection; filling in the last required field then persists everything typed before it in one shot (thanks to merging over record.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 :entity association before passing it in — that's what keeps this component DB-free to render; see "The host LiveView owns" above.
  • mode (required) — :edit renders 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 omits mode altogether — 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 to nil — safe to omit entirely) — locale passed straight through to PhoenixKitEntities.FormBuilder.build_fields/3 as lang_code (and to entity loading, when the entity isn't already preloaded). In :edit mode this drives FormBuilder's own field-level "translations" resolution (labels + option text; see FormBuilder's moduledoc for the display-only contract). The static :readonly view resolves the same field["translations"] map directly via FormBuilder.translated_label/2 / translated_option_label/3 — a stored value (e.g. "Must") always renders its translation for lang (e.g. "Чёрный"), falling back to the original label/option text whenever lang is nil or no translation entry exists. Either way, the value actually persisted in record.data is never touched. This component is one of the surfaces that DOES resolve field-level "translations" — see FormBuilder's moduledoc, "Which surfaces resolve this", for the full list of which callers do and don't.
  • actor (optional, defaults to nil — safe to omit entirely) — the acting user (a struct with a :uuid field) or nil. Threaded through to EntityData.update/3 as actor_uuid: for activity logging.
  • submit_label (optional, defaults to nil — safe to omit entirely) — nil hides the submit button entirely; any other string renders it as the button's text.
  • persist_statuses (optional, defaults to nil — safe to omit entirely) — a list of status strings (e.g. ["draft"]), threaded through to EntityData.update/3 as require_status: on both the autosave and submit persist paths. Recommended for any host LiveView that can have more than one live :edit instance 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_statuses behavior: 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 at debug (not error; the guard doing its job isn't a failure), previous record kept, no :saved/:submitted message sent. Be precise about what this guards: it's a status-transition check, not optimistic locking on data. Two sessions both legitimately editing the same record while its status stays within persist_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 params phx-submit just sent (the same merge-over-record.data path as autosave, so the last edit is never lost to the debounce window) and only sends this message once that save succeeds — updated_record reflects it. The component never changes status itself — 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.