MishkaCmsCoreWeb. Live. Admin. Dashboard. Runtime. PreviewHelpers
(Mishka CMS Core v0.0.1)
Copy Markdown
View Source
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_asyncon 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.
Summary
Functions
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.
Route one of the modal's async operations to its own handler.
Leave edit mode.
Clear preview state (the modal close button also hides it client-side).
Report a force compile and refresh the Pipeline tab from what actually happened.
Discard a draft and refresh the picker. id names WHICH — nil means the selected one.
Server-initiated close (e.g. after a successful Configuration-form save): hide AND reset.
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.
Copy the open component as a new one, off the LiveView process — see
MishkaCmsCore.Runtime.ComponentCopy.
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.
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.
The :preview assign as a closed modal holds it — every key the modal reads, at its resting value.
Build this record's runtime module NOW, whether or not it is marked for precompilation.
The same preview as a path with no host, so the browser loads it from the admin's own address.
True when the resource's preview modal includes a Configuration (form) tab.
Where the modal's "Open page" goes: an active page's own URL on its site's host, or nil.
Place the loaded preview (or fail closed) into the :preview assign.
The shared modal DOM id (used by the gervaz show/hide JS).
Start a brand-new draft: deselect + open the Code editor seeded from the page's template — for a writer of the record.
Open the modal for id (Overview tab) and load the record off the LiveView process.
Open the modal for id straight on the Configuration (form) tab — the edit pencil / Edit button.
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.
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.
Publish the selected draft to its live record; keep the modal open and refresh the picker.
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.
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 a version (master/tenant aware) and reload the current page, off the LiveView process.
Create or update a draft from the Code editor (+ label), then reload the picker — for a writer of the record.
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 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.
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.
Switch the Preview tab between the pending "draft" and the live "original".
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 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.
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).
Expand/collapse a version's change details.
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.
Place a freshly-read page of versions (and any flash the work produced) into the assign.
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.
Functions
@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.
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.
Leave edit mode.
Clear preview state (the modal close button also hides it client-side).
Report a force compile and refresh the Pipeline tab from what actually happened.
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.
Server-initiated close (e.g. after a successful Configuration-form save): hide AND reset.
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.
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.
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.
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.
@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.
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.
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.
True when the resource's preview modal includes a Configuration (form) tab.
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"
Place the loaded preview (or fail closed) into the :preview assign.
The shared modal DOM id (used by the gervaz show/hide JS).
Start a brand-new draft: deselect + open the Code editor seeded from the page's template — for a writer of the record.
Open the modal for id (Overview tab) and load the record off the LiveView process.
Open the modal for id straight on the Configuration (form) tab — the edit pencil / Edit button.
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.
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 the selected draft to its live record; keep the modal open and refresh the picker.
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.
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 a version (master/tenant aware) and reload the current page, off the LiveView process.
Create or update a draft from the Code editor (+ label), then reload the picker — for a writer of the record.
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 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.
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.
Switch the Preview tab between the pending "draft" and the live "original".
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 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.
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).
Expand/collapse a version's change details.
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.
Place a freshly-read page of versions (and any flash the work produced) into the assign.
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.