# `MishkaCmsCoreWeb.Live.Admin.Dashboard.Runtime.PreviewHelpers`
[🔗](https://github.com/mishka-group/mishka_cms_core/blob/v0.0.1/lib/mishka_cms_core_web/live/admin/dashboard/runtime/preview_helpers.ex#L5)

Shared wiring for the runtime "Preview" modal across every module-backed runtime
admin table (Page / Component / Layout / ErrorPage).

Each page keeps its own thin `handle_info` / `handle_async` / `handle_event` clauses
and delegates here, reusing the `runtime_status_opts` that
`MishkaCmsCoreWeb.Live.Admin.Dashboard.Runtime.RuntimeStatusLive.setup/2` already
stored (resource + master/tenant action + loads):

    def mount(...), do: socket |> RuntimeStatusLive.setup(...) |> PreviewHelpers.setup()

    def handle_info({:row_action, "show_preview_modal", %{"id" => id}}, s),
      do: {:noreply, PreviewHelpers.open(s, id)}

    def handle_async(name, result, s) when name in [:load_preview, :preview_versions, :preview_compile],
      do: {:noreply, PreviewHelpers.async_result(s, name, result)}

    def handle_event("preview_set_tab", %{"tab" => t}, s),
      do: {:noreply, PreviewHelpers.set_tab(s, t)}

Render the modal once per page with `<RuntimePreviewModal.modal preview={@preview} />`.

## One modal, and everything in it is lazy

Viewing and editing are the same modal: `open/2` lands on Overview, `open_configuration/2` on the
Configuration tab that hosts the resource's `MishkaGervaz.Form.Web.Live` form. A page therefore
mounts that form ONCE, which is also why it has to be here — two mounts of one form component on a
page is two answers to "which record is open", and the DOM ids collide.

Nothing in the modal does work before somebody asks for it:

  * the record + status + drafts load in `start_async` on open, behind a spinner;
  * the Preview tab builds its document — and, for a lazy record, compiles it — on first visit;
  * the Configuration tab mounts the form, and so reads the row and its relation options, on
    first visit;
  * the Versions tab pages and restores off the LiveView process.

That last property is the point of `async_result/3`: separate async names, because
`start_async/3` cancels an in-flight task that shares a name.

## The preview document is built here, so the CSP nonce is read here

The modal only hands `@html` / `@edit_html` to an `<iframe srcdoc>`; the document itself is
assembled by `MishkaCmsCore.Runtime.RuntimePreview`, from this module — `set_tab/2` on the first
visit to the Preview tab, and `rerender/2` on an unsaved edit. A srcdoc document inherits the
ADMIN policy, whose `style-src-elem` names a nonce and not `'unsafe-inline'`, so the document's
`<style>` needs one or the whole preview renders unstyled once that policy is enforced.

Both read `socket.assigns[:csp_nonce]` — through `Access`, so a page that somehow never ran
`MishkaCmsCore.Runtime.CspNonce` gets `nil` and a `<style>` with no attribute rather than a crash.
Both also run in the LiveView process rather than the async load task, which is what lets them
read the socket at all.

## Nothing compiles until the Preview tab

Rendering the document runs the record for real, and a record is built by the first thing that
renders it. Doing that on modal open meant clicking the eye compiled a component, so the Pipeline
tab reported "Live" about one nothing had ever rendered. Overview, Code, Versions and Pipeline
read the record and never run it; only the Preview tab does.

# `assign_access`

```elixir
@spec assign_access(Phoenix.LiveView.Socket.t(), module()) ::
  Phoenix.LiveView.Socket.t()
```

What a list screen of `resource` offers its reader, read once at mount: the reader's permissions
loaded onto `:current_user` — so the table's row actions and this modal ask without a query each —
and `:writable?`, whether it writes the resource's records.

# `async_result`

Route one of the modal's async operations to its own handler.

Each page carries a single `handle_async/3` clause guarded on the names it can start and delegates
here. They are SEPARATE names on purpose: `start_async/3` cancels an in-flight task of the same
name, so sharing one would mean a click on the Versions pager killing the record load that opened
the modal, or a tab switch killing a compile or a duplicate that is still running.

# `cancel_edit`

Leave edit mode.

# `close`

Clear preview state (the modal close button also hides it client-side).

# `compiled`

Report a force compile and refresh the Pipeline tab from what actually happened.

# `discard_draft`

Discard a draft and refresh the picker. `id` names WHICH — `nil` means the selected one.

A real delete — these resources do not archive, so the row does not survive and the discard is
reportable only on the live activity feed, never from history.

Two buttons reach this. The bar's Discard is always about the draft in focus and passes no id; a
badge's ✖ names its own draft, which may be any one on the page. That difference is the whole
reason the id is a parameter: `preview.draft` would have deleted the wrong row for every badge
except the selected one.

# `dismiss`

Server-initiated close (e.g. after a successful Configuration-form save): hide AND reset.

# `drafts_paginate`

Load a page of the drafts picker (selection is left unchanged). `page_num` is a number or the text
the browser sent; text that is not a whole number leaves the picker as it is.

# `duplicate`

Copy the open component as a new one, off the LiveView process — see
`MishkaCmsCore.Runtime.ComponentCopy`.

A copy is a whole create — validations, a signature, a version row and a compile job — so it runs
in `start_async(:preview_duplicate, …)` and the button reports itself busy meanwhile. Only offered,
and only run, when the modal decided `duplicable?`: the event is something a browser can send
whether or not the button was drawn.

# `duplicated`

Close the modal on a copy, which lands at the top of the table through the resource's own
broadcast; keep it open on a refusal, so the reader still has the source in front of them.

# `edit_code`

Enter Code-tab edit mode, seeding the editor from the pending draft if one exists — for a writer of
the record; anyone else is told what it needs.

# `empty`

```elixir
@spec empty() :: map()
```

The `:preview` assign as a closed modal holds it — every key the modal reads, at its resting value.

Public so a test can build a state by merging onto it rather than copying the shape. A copy goes
stale the moment a key is added here, and the failure is a `KeyError` from the template rather
than anything about the behaviour under test.

# `force_compile`

Build this record's runtime module NOW, whether or not it is marked for precompilation.

A lazy record is built by the first request that needs it, which is the right default and a poor
way to find out that its template no longer compiles: the person who broke it is not the person
who discovers it. This is the same call the runtime makes on that first request, with `:update` so
an already-loaded module is rebuilt rather than reported as fine.

It also names a real cost, which is why it is a button and not something the modal does on its own:
a compiled module is code held in RAM for the life of the node.

# `frame_url`

The same preview as a path with no host, so the browser loads it from the admin's own address.

That address is the master site's for a master and a site admin's own site's for them:
`MishkaCmsCore.Runtime.Plugs.SiteLookupPlug` signs neither in anywhere else. So the frame is
always same-origin, as the admin's `frame-src 'self'` requires, and never names a host of its own.
Building it from the endpoint's host named `localhost` in production, where a multi-site endpoint
has no host configured, and the frame stayed empty.

# `has_config?`

True when the resource's preview modal includes a Configuration (form) tab.

# `live_url`

```elixir
@spec live_url(map(), module()) :: String.t() | nil
```

Where the modal's "Open page" goes: an active page's own URL on its site's host, or `nil`.

A stored path is a route, not always a URL. One that captures only `:lang` is filled with a
language the page is served in, at the address the site's URL mode gives it
(`MishkaCmsCore.Runtime.Languages.url/3`: `/:lang/blog` opens `/blog`, or `/en/blog` on a site
that prefixes every language). One that captures a record — `/:lang/blog/:slug`,
`/:lang/docs/:workspace/:section` — is the template of many URLs and the address of none, so it
has no page to open: the button is hidden, and "Copy link" shares the preview instead of a path
that answers 404.

    live_url(%Page{active: true, path: "/:lang/blog", language: "en", site: site}, Page)
    #=> "http://site-b.localhost:4000/blog"

# `loaded`

Place the loaded preview (or fail closed) into the `:preview` assign.

# `modal_id`

The shared modal DOM id (used by the gervaz show/hide JS).

# `new_draft`

Start a brand-new draft: deselect + open the Code editor seeded from the page's template — for a
writer of the record.

# `open`

Open the modal for `id` (Overview tab) and load the record off the LiveView process.

# `open_configuration`

Open the modal for `id` straight on the Configuration (form) tab — the edit pencil / Edit button.

# `open_new`

Open the modal on an empty Configuration form to ADD a record (the page's + button) — for a writer
of the resource; anyone else is told what it needs.

# `preview_url`

Absolute shareable URL for a record's `/_preview/<resource>/:token`, on the domain of the admin
who shares it: the master site's for a master, the admin's own site's for a site admin. With no
admin given, the record's own site's, or the master site's for a record with no site. Resource
segment (`page`/`layout`/`error_page`) is derived from the struct.

The domain is read from the site records, never from a request. The preview route serves a record
on its own site's domain and on the master site's
(`MishkaCmsCore.Runtime.DraftPreviewAssets.served_on?/2`), so the link opens where it is sent.
For the URL the modal FRAMES, see `frame_url/1`.

# `publish_draft`

Publish the selected draft to its live record; keep the modal open and refresh the picker.

# `reload_drafts`

Reload the picker after a save/publish/discard. `page_num` is the page to show, `select_id` the
draft to (re)select (`nil` → the latest in view). Leaves edit mode.

# `rerender`

Re-render the (unsaved) edited template and jump to the Preview tab showing it as the Draft view
with the Draft toggle active. Renders to a standalone doc (`edit_html`) — the live `Original`
iframe is left untouched. Rendering runs the edit, so it is a writer's alone.

# `restore_version`

Restore a version (master/tenant aware) and reload the current page, off the LiveView process.

# `save_draft`

Create or update a draft from the Code editor (+ label), then reload the picker — for a writer of
the record.

# `save_source`

Save the Code tab's edit straight to the record, for a resource with no drafts — a JS Hook, a
Stylesheet, a Component — and reopen the modal on the Code tab with what was saved. It runs the
update the Configuration form runs, as the master or as the site, so every rule that form meets
is met here; a refusal keeps the editor open with the reason.

# `select_draft`

Select a draft from the picker. It's *context-aware*: on the Code tab it just swaps which draft's
data the editor/source shows (stays on Code); on the Preview tab it renders THAT draft (switches
to the Draft view). It never forces a tab change.

# `set_device`

# `set_error_status`

Select which ErrorPage status code (404/500/…) the Preview tab renders. An ErrorPage holds many
templates; its preview is a full LiveView (`/_preview/error_page/:token?status=…`), so this just
records the chosen status and bumps the nonce — the modal rebuilds the iframe URL/id to that code.
Clears any unsaved-edit doc so the selector always shows the live render.

# `set_preview_source`

Switch the Preview tab between the pending `"draft"` and the live `"original"`.

# `set_tab`

# `setup`

Init the `:preview` assign and attach the realtime status hook. Call AFTER
`RuntimeStatusLive.setup/2` and BEFORE `ActivityFeed.setup/2` (the feed hook halts the
oban/compile broadcasts, so this must see them first) — same ordering rule the expand row
follows. The hook recomputes the open preview's `status` (the Pipeline tab) on the same
signals that drive the expand row: oban job events and the lazy `__runtime_compiled__`
broadcast, so a page that compiles on a first visitor request flips to "Live" in the modal
too.

# `subscribe_drafts`

Subscribe the LiveView process to a resource's draft pubsub topics (so the open modal's picker
stays realtime). Master gets the bare topics; a tenant also its own site's. No-op for
non-draftable resources.

# `sync_draft`

Realtime sync of the open modal's picker from a draft notification (a publish/discard/edit that
happened in another tab/session). Reloads the current page of drafts and keeps the selected one if
it's still pending — which it isn't once published/discarded, so the selection (and the Code
editor) fall back to the latest remaining, or clear when none are left. Works for Page and Layout
drafts (the source id is read via the open resource's draft spec).

# `toggle_version`

Expand/collapse a version's change details.

# `type_code`

Keep what is typed in the Code tab's editor (`source`, and the draft's `label` when the form has
one), so the editor still holds it after a reconnect and a refused save leaves it as it was.

# `versions_loaded`

Place a freshly-read page of versions (and any flash the work produced) into the assign.

# `versions_paginate`

Go to a (clamped) versions page and reload it, off the LiveView process. `page` is a number or the
text the browser sent; text that is not a whole number leaves the modal as it is.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
