PhoenixKitWeb.Components.FeaturedImage (phoenix_kit v2.37.5)

Copy Markdown View Source

One entity's "main image" control: a thumbnail, an always-visible actions menu (Change / Remove, plus View when a preview is wired up) and the whole media-picker protocol behind it.

The component owns the picker — it mounts MediaSelectorModal itself, resets its own open flag on both of the modal's replies, refuses anything that is not a live image — and leaves the host exactly one required message. It writes nothing: where the pointer lives (a data key, a metadata field, a setting, a column) and when it is saved (at once, or with the surrounding form) are the host's business.

Usage

<.live_component
  module={PhoenixKitWeb.Components.FeaturedImage}
  id="order-featured"
  uuid={@order.featured_image_uuid}
  picker_scope={@order.storage_folder_uuid && {:folder, @order.storage_folder_uuid}}
  phoenix_kit_current_user={@current_user}
  readonly={@order.deleted?}
  on_preview={{"show_order_card", %{uuid: @order.uuid}}}
/>

def handle_info({PhoenixKitWeb.Components.FeaturedImage, "order-featured", {:set_featured, uuid}}, socket) do
  # `uuid` is a file uuid, or `nil` when the user removed the image.
  # Authorize, persist, then assign the new value back — the component
  # draws whatever `uuid` it is handed and nothing else.
  {:noreply, socket}
end

The payload has the same shape as MediaBrowser's :featured relay, so one host function can write both.

Attrs

There are no required attrs beyond id.

  • id — required. Every DOM id and data-role is derived from it, and it travels in every message the component sends.

  • uuid — the current pointer, or nil. The component is controlled: it draws exactly what the host passes and never updates it optimistically. Anything that is not a live image (a uuid with no row, a trashed file, a non-image, a failed upload, a string that is not a UUID at all) is drawn as a "missing" placeholder whose menu still offers Remove, so a dangling pointer can always be cleared. A readonly viewer sees nothing for it (or the :empty slot), never a warning they cannot act on. "" counts as nil.

  • picker_scope — where the picker looks and where uploads land:

    • {:folder, folder_uuid} — the entity's folder. The picker is narrowed to it (and its subfolders) and files uploaded from it are filed there.
    • :lazy — the entity has no folder yet; the host creates one on demand (see "Lazy scope" below).
    • nil (default) — choosing is unavailable. The modal is never mounted: with no scope it would browse the whole library.

    Not named scope: that is @phoenix_kit_current_scope everywhere else in PhoenixKit. There is deliberately no whole-library value — an upload made outside any folder is an orphan candidate for storage's cleanup.

  • phoenix_kit_current_user — required by the picker for uploads.

  • readonly — false. true draws the thumbnail only: no menu, no picker, no empty placeholder. Also enforced on every event and message the component handles, since an event can be sent around the UI.

  • size — :md. :sm 48px, :md 64px, :lg 96px (the :lg thumbnail uses the small variant, the others thumbnail).

  • shape — :square (rounded-lg, default) or :circle (avatars).

  • label — the picker's title and the control's accessible name; defaults to a translated "Featured image".

  • on_preview — what a click on the thumbnail does (and adds a "View" item to the menu). One of "event", {"event", %{key: value}} or a %Phoenix.LiveView.JS{}. There is no phx-target, so the event goes to the host LiveView. The component only knows the file's uuid, whereas a host usually needs its entity's, so the map is sent as phx-value-* params: {"show_card", %{uuid: @order.uuid}} arrives as %{"uuid" => order_uuid}. Values must be strings or numbers. Left nil, a click on the thumbnail opens the picker instead (when choosing is possible).

  • confirm_remove — a data-confirm text for Remove, for hosts that write the change at once and want to ask first.

  • report_close — false. true sends :picker_closed (below).

Slots

  • :empty — replaces the empty placeholder, e.g. an avatar's initials. It keeps the control's size and shape, still opens the picker when choosing is possible, and is drawn for a readonly viewer as well (they should still see the initials). It also stands in for a dangling pointer: the frame is marked "Image missing" (title and screen-reader text) and the menu still offers Remove, but the host's placeholder is what is seen.
  • :empty_hint — drawn instead of the placeholder when picker_scope is nil, e.g. an "Upload files first" button that switches to the Files tab. Not drawn for readonly (read-only wins).

Required host wiring — silent failure otherwise

This is a LiveComponent, so it has no handle_info of its own: it reports to the host LiveView process with send/2, and the host MUST handle

{PhoenixKitWeb.Components.FeaturedImage, id, {:set_featured, uuid | nil}}

Forget it and the user's choice is silently dropped (no crash, no warning). A host that funnels every message into a catch-all handle_info must match this one before it.

The other two messages are opt-in, so a host that does not use them needs no clause for them:

  • {FeaturedImage, id, :scope_requested} — only with picker_scope: :lazy.
  • {FeaturedImage, id, :picker_closed} — only with report_close: true. Sent whenever the picker goes away, however it ended (chosen, cancelled, rejected), always after :set_featured — except for a readonly component, which sends nothing at all. A host with a file list of its own refreshes it here: uploads made inside the picker land in the folder even when nothing was chosen.

Also needed by the app, not the component: the RowMenu hook (from PhoenixKitHooks) registered in the host's app.js, or the actions menu is dead markup; and Phoenix LiveView 1.1 or newer — 1.2.12 or newer is recommended, portals were still being fixed until then (see "The modal lives in a portal").

Reacting to a file trashed elsewhere

The Storage lookup behind display is memoized per uuid (load_display/1) — once settled (:empty, :ok, :dangling) it is not looked up again on its own, so a file trashed, restored, or permanently deleted in another tab or browser (see Storage.subscribe_to_file_events/0) does not change what is drawn here until the host pokes it:

send_update(FeaturedImage, id: id, refresh: true)

A host wires this itself — the component has no handle_info of its own — by subscribing on mount and matching the event's uuid against whatever it currently has pointed at this instance:

def mount(socket) do
  if connected?(socket), do: Storage.subscribe_to_file_events()
  {:ok, socket}
end

@one ~w(phoenix_kit_file_trashed phoenix_kit_file_restored phoenix_kit_file_deleted)a
@many ~w(phoenix_kit_files_trashed phoenix_kit_files_restored phoenix_kit_files_deleted)a

# One file trashed/restored/deleted directly...
def handle_info({event, uuid}, socket) when event in @one,
  do: refresh_if_mine(socket, [uuid])

# ...or swept up with its folder, announced once for the whole folder.
def handle_info({event, uuids}, socket) when event in @many,
  do: refresh_if_mine(socket, uuids)

defp refresh_if_mine(socket, uuids) do
  if socket.assigns.order.featured_image_uuid in uuids,
    do: send_update(FeaturedImage, id: "order-featured", refresh: true)

  {:noreply, socket}
end

Unlike recheck: true (used internally while a file is still :processing), refresh: true re-resolves unconditionally — the one to reach for whenever the file's status may have changed regardless of what is currently displayed. See MediaBrowser.attach_file_event_forwarding/1 for the same subscribe/dispatch shape wired through an attach_hook instead of a plain handle_info clause.

Caveat for a host that also runs MediaBrowser.setup_uploads/1: that hook :halts the trashed / restored / deleted events after forwarding them to its browsers, so the handle_info clause above never sees them. Attach your own :handle_info hook before setup_uploads/1 (hooks run in attach order) and return {:cont, socket} from it.

Lazy scope

Most hosts create the entity's folder on the first click, not on page load. With picker_scope: :lazy a click on Choose/Change opens nothing; it sends the host {FeaturedImage, id, :scope_requested}, and the host answers:

def handle_info({FeaturedImage, id, :scope_requested}, socket) do
  case ensure_folder(socket) do
    {:ok, socket} ->
      # `socket` now carries the folder, so the next render passes
      # `picker_scope={{:folder, uuid}}`; then ask the component to open.
      send_update(FeaturedImage, id: id, open_picker: true)
      {:noreply, socket}

    :refused ->
      # The host explains itself (its own flash); nothing opens.
      send_update(FeaturedImage, id: id, open_picker: false)
      {:noreply, put_flash(socket, :error, "Restore this contact first")}

    {:error, message} ->
      # ...or let the component show the message under the control.
      send_update(FeaturedImage, id: id, error: message)
      {:noreply, socket}
  end
end

The reply does not carry the folder: the component takes its scope from the picker_scope attr only, so the host must have updated its assign before replying. If open_picker: true arrives while the scope is still not a folder, nothing opens (and a warning is logged). While an answer is pending, further clicks do nothing. A :lazy host that never answers leaves the control inert — every branch above must reply.

Nested LiveViews (not verified)

The actions menu is a RowMenu whose <ul> is moved to <body> while open, and the picker is a <.portal>. Both work in a host's main LiveView; neither has been exercised inside a live_render-ed child LiveView (an embedded project view, say), where a portaled phx-target={@myself} click might not find its component. From LiveView's sources this looks fine, but no test can run the client — check it in a browser before relying on it, and fall back to table_row_menu mode="inline" if it misbehaves.

The modal lives in a portal

The picker is rendered through <.portal target="body">. MediaSelectorModal has a <form> and a file input of its own, while most hosts place this control inside their own <.form>; nested forms are invalid HTML and the upload would post to the wrong form. The portal lifts the modal out of the form (and out of any card or overflow clipping). In tests, content inside a portal is invisible to has_element?/2 and render_click/2: assert on the portal element instead (render(element(view, "#<id>-portal"))) and drive the modal's reply with send_update(FeaturedImage, id: id, media_selected: [uuid]).

All the component's own buttons are type="button", so it is safe inside a host form.

The component does not authorize

It draws Change/Remove for anyone it is not readonly for, and it relays what the user picked. Whether this user may change this entity's image, whether the chosen file belongs in this entity's folder, and whether the write goes through are all the host's to decide, in the :set_featured handler. The component checks only that a chosen file exists, is an image, is not in the trash and is not a system-managed row (an edited image's hidden original). The same goes for a staged form: a dangling pointer is only drawn as missing; it stays in the host's state and will be saved as it was unless the host validates on write.

Not for lists

Each instance makes one Storage query when its uuid changes (memoized per uuid). For a table of rows draw the thumbnails from data the host already has instead.

Actions and their data-roles

Everything is addressed as "<name>-<id>": featured-image, row, thumb, thumb-img, open-picker, empty, empty-hint, dangling, processing, menu, change, view, remove, error. The root also carries data-state (empty, ok, processing or dangling). The portal is #<id>-portal and the modal inside it #<id>-selector.

Summary

Functions

render(assigns)

Callback implementation for Phoenix.LiveComponent.render/1.