add live preview pane to page editor
All checks were successful
deploy / deploy (push) Successful in 4m55s
All checks were successful
deploy / deploy (push) Successful in 4m55s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6fbd654d57
commit
b340c24aa1
@ -458,7 +458,7 @@ See: [plan](docs/plans/shipping-sync.md) for implementation details
|
|||||||
See: [docs/plans/analytics-v2.md](docs/plans/analytics-v2.md) for v2 plan
|
See: [docs/plans/analytics-v2.md](docs/plans/analytics-v2.md) for v2 plan
|
||||||
|
|
||||||
### Page Editor
|
### Page Editor
|
||||||
**Status:** In progress — Stage 7 of 9 complete, 1320 tests
|
**Status:** In progress — Stage 7b of 9 complete, 1326 tests
|
||||||
|
|
||||||
Database-driven page builder. Every page is a flat list of blocks stored as JSON — add, remove, reorder, and edit blocks on any page. One generic renderer for all pages (no page-specific render functions). Portable blocks (hero, featured_products, image_text, etc.) work on any page. Page-specific blocks (product_hero, cart_items, etc.) are restricted to their native page. Block data loaders dynamically load data based on which blocks are on the page. ETS-cached page definitions. Mobile-first admin editor with live preview, undo/redo, accessible reordering (no drag-and-drop), inline settings forms, and "reset to defaults". CSS-driven page layout (not renderer-driven).
|
Database-driven page builder. Every page is a flat list of blocks stored as JSON — add, remove, reorder, and edit blocks on any page. One generic renderer for all pages (no page-specific render functions). Portable blocks (hero, featured_products, image_text, etc.) work on any page. Page-specific blocks (product_hero, cart_items, etc.) are restricted to their native page. Block data loaders dynamically load data based on which blocks are on the page. ETS-cached page definitions. Mobile-first admin editor with live preview, undo/redo, accessible reordering (no drag-and-drop), inline settings forms, and "reset to defaults". CSS-driven page layout (not renderer-driven).
|
||||||
|
|
||||||
@ -469,7 +469,8 @@ Database-driven page builder. Every page is a flat list of blocks stored as JSON
|
|||||||
4. ~~Wire shop pages — Collection, PDP, Cart, Search~~ ✅
|
4. ~~Wire shop pages — Collection, PDP, Cart, Search~~ ✅
|
||||||
5. ~~Wire order pages + theme preview — CheckoutSuccess, Orders, OrderDetail, theme editor~~ ✅
|
5. ~~Wire order pages + theme preview — CheckoutSuccess, Orders, OrderDetail, theme editor~~ ✅
|
||||||
6. ~~Admin editor — page list + block management~~ ✅ (`660fda9`)
|
6. ~~Admin editor — page list + block management~~ ✅ (`660fda9`)
|
||||||
7. ~~Admin editor — inline block settings editing~~ ✅
|
7. ~~Admin editor — inline block settings editing~~ ✅ (`3f97742`)
|
||||||
|
7b. ~~SettingsField struct + repeater field type for info_card items~~ ✅ (`6fbd654`)
|
||||||
8. **Next →** Live preview — split layout with real-time preview
|
8. **Next →** Live preview — split layout with real-time preview
|
||||||
9. Undo/redo + polish — history stacks, keyboard shortcuts, animations
|
9. Undo/redo + polish — history stacks, keyboard shortcuts, animations
|
||||||
|
|
||||||
@ -480,7 +481,8 @@ Database-driven page builder. Every page is a flat list of blocks stored as JSON
|
|||||||
- `lib/berrypod_web/live/admin/pages/` — Index (page list) + Editor (block management)
|
- `lib/berrypod_web/live/admin/pages/` — Index (page list) + Editor (block management)
|
||||||
- `test/berrypod/pages_test.exs` — 34 tests
|
- `test/berrypod/pages_test.exs` — 34 tests
|
||||||
- `test/berrypod_web/page_renderer_test.exs` — 18 tests
|
- `test/berrypod_web/page_renderer_test.exs` — 18 tests
|
||||||
- `test/berrypod_web/live/admin/pages_test.exs` — 36 tests
|
- `lib/berrypod/pages/settings_field.ex` — typed struct for settings schema fields
|
||||||
|
- `test/berrypod_web/live/admin/pages_test.exs` — 42 tests
|
||||||
|
|
||||||
See: [docs/plans/page-builder.md](docs/plans/page-builder.md) for full plan
|
See: [docs/plans/page-builder.md](docs/plans/page-builder.md) for full plan
|
||||||
|
|
||||||
|
|||||||
@ -1144,6 +1144,74 @@
|
|||||||
color: color-mix(in oklch, var(--t-text-primary) 30%, transparent);
|
color: color-mix(in oklch, var(--t-text-primary) 30%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Page editor split layout */
|
||||||
|
|
||||||
|
.page-editor-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-editor-pane-hidden-mobile {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-editor-preview-hidden-mobile {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-editor-preview-pane {
|
||||||
|
border: 1px solid color-mix(in oklch, var(--t-text-primary) 15%, transparent);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-editor-preview {
|
||||||
|
transform: scale(0.55);
|
||||||
|
transform-origin: top left;
|
||||||
|
width: 181.82%; /* 1/0.55 */
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-editor-toggle-preview {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 64em) {
|
||||||
|
.page-editor-container {
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-editor-pane {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-editor-pane-hidden-mobile {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-editor-preview-pane {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
max-height: calc(100vh - 14rem);
|
||||||
|
overflow-y: auto;
|
||||||
|
position: sticky;
|
||||||
|
top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-editor-preview-hidden-mobile {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-editor-toggle-preview {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Block list in editor */
|
/* Block list in editor */
|
||||||
|
|
||||||
.block-list {
|
.block-list {
|
||||||
|
|||||||
@ -1,14 +1,22 @@
|
|||||||
defmodule BerrypodWeb.Admin.Pages.Editor do
|
defmodule BerrypodWeb.Admin.Pages.Editor do
|
||||||
use BerrypodWeb, :live_view
|
use BerrypodWeb, :live_view
|
||||||
|
|
||||||
alias Berrypod.Pages
|
alias Berrypod.{Media, Pages}
|
||||||
alias Berrypod.Pages.{BlockTypes, Defaults, SettingsField}
|
alias Berrypod.Pages.{BlockTypes, Defaults}
|
||||||
|
alias Berrypod.Products.ProductImage
|
||||||
|
alias Berrypod.Theme.{Fonts, PreviewData}
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(%{"slug" => slug}, _session, socket) do
|
def mount(%{"slug" => slug}, _session, socket) do
|
||||||
page = Pages.get_page(slug)
|
page = Pages.get_page(slug)
|
||||||
allowed_blocks = BlockTypes.allowed_for(slug)
|
allowed_blocks = BlockTypes.allowed_for(slug)
|
||||||
|
|
||||||
|
preview_data = %{
|
||||||
|
products: PreviewData.products(),
|
||||||
|
categories: PreviewData.categories(),
|
||||||
|
cart_items: PreviewData.cart_items()
|
||||||
|
}
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, page.title)
|
|> assign(:page_title, page.title)
|
||||||
@ -20,7 +28,11 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
|||||||
|> assign(:show_picker, false)
|
|> assign(:show_picker, false)
|
||||||
|> assign(:picker_filter, "")
|
|> assign(:picker_filter, "")
|
||||||
|> assign(:expanded, MapSet.new())
|
|> assign(:expanded, MapSet.new())
|
||||||
|> assign(:live_region_message, nil)}
|
|> assign(:live_region_message, nil)
|
||||||
|
|> assign(:show_preview, false)
|
||||||
|
|> assign(:preview_data, preview_data)
|
||||||
|
|> assign(:logo_image, Media.get_logo())
|
||||||
|
|> assign(:header_image, Media.get_header())}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
@ -313,6 +325,10 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
|||||||
|> put_flash(:info, "Page reset to defaults")}
|
|> put_flash(:info, "Page reset to defaults")}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_event("toggle_preview", _params, socket) do
|
||||||
|
{:noreply, assign(socket, :show_preview, !socket.assigns.show_preview)}
|
||||||
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
@ -326,6 +342,16 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
|||||||
<.header>
|
<.header>
|
||||||
{@page_data.title}
|
{@page_data.title}
|
||||||
<:actions>
|
<:actions>
|
||||||
|
<button
|
||||||
|
phx-click="toggle_preview"
|
||||||
|
class="admin-btn admin-btn-sm admin-btn-ghost page-editor-toggle-preview"
|
||||||
|
>
|
||||||
|
<.icon
|
||||||
|
name={if @show_preview, do: "hero-pencil-square", else: "hero-eye"}
|
||||||
|
class="size-4"
|
||||||
|
/>
|
||||||
|
{if @show_preview, do: "Edit", else: "Preview"}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
phx-click="reset_defaults"
|
phx-click="reset_defaults"
|
||||||
data-confirm="Reset this page to its default layout? Your changes will be lost."
|
data-confirm="Reset this page to its default layout? Your changes will be lost."
|
||||||
@ -353,26 +379,51 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
|||||||
Unsaved changes
|
Unsaved changes
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<%!-- Block list --%>
|
<div class="page-editor-container">
|
||||||
<div class="block-list" role="list" aria-label="Page blocks">
|
<%!-- Editor pane --%>
|
||||||
<.block_card
|
<div class={[
|
||||||
:for={{block, idx} <- Enum.with_index(@blocks)}
|
"page-editor-pane",
|
||||||
block={block}
|
@show_preview && "page-editor-pane-hidden-mobile"
|
||||||
idx={idx}
|
]}>
|
||||||
total={length(@blocks)}
|
<%!-- Block list --%>
|
||||||
expanded={@expanded}
|
<div class="block-list" role="list" aria-label="Page blocks">
|
||||||
/>
|
<.block_card
|
||||||
|
:for={{block, idx} <- Enum.with_index(@blocks)}
|
||||||
|
block={block}
|
||||||
|
idx={idx}
|
||||||
|
total={length(@blocks)}
|
||||||
|
expanded={@expanded}
|
||||||
|
/>
|
||||||
|
|
||||||
<div :if={@blocks == []} class="block-list-empty">
|
<div :if={@blocks == []} class="block-list-empty">
|
||||||
<p>No blocks on this page yet.</p>
|
<p>No blocks on this page yet.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- Add block button --%>
|
||||||
|
<div class="block-actions">
|
||||||
|
<button phx-click="show_picker" class="admin-btn admin-btn-outline block-add-btn">
|
||||||
|
<.icon name="hero-plus" class="size-4" /> Add block
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<%!-- Add block button --%>
|
<%!-- Preview pane --%>
|
||||||
<div class="block-actions">
|
<div class={[
|
||||||
<button phx-click="show_picker" class="admin-btn admin-btn-outline block-add-btn">
|
"page-editor-preview-pane",
|
||||||
<.icon name="hero-plus" class="size-4" /> Add block
|
!@show_preview && "page-editor-preview-hidden-mobile"
|
||||||
</button>
|
]}>
|
||||||
|
<.preview_pane
|
||||||
|
slug={@slug}
|
||||||
|
blocks={@blocks}
|
||||||
|
page_data={@page_data}
|
||||||
|
preview_data={@preview_data}
|
||||||
|
theme_settings={@theme_settings}
|
||||||
|
generated_css={@generated_css}
|
||||||
|
logo_image={@logo_image}
|
||||||
|
header_image={@header_image}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%!-- Block picker modal --%>
|
<%!-- Block picker modal --%>
|
||||||
@ -385,6 +436,126 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
|||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# ── Preview ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
defp preview_pane(assigns) do
|
||||||
|
# Build a temporary page struct from working state
|
||||||
|
page = %{slug: assigns.slug, title: assigns.page_data.title, blocks: assigns.blocks}
|
||||||
|
|
||||||
|
preview =
|
||||||
|
assigns
|
||||||
|
|> assign(:page, page)
|
||||||
|
|> assign(:mode, :preview)
|
||||||
|
|> assign(:products, assigns.preview_data.products)
|
||||||
|
|> assign(:categories, assigns.preview_data.categories)
|
||||||
|
|> assign(:cart_items, PreviewData.cart_drawer_items())
|
||||||
|
|> assign(:cart_count, 2)
|
||||||
|
|> assign(:cart_subtotal, "£72.00")
|
||||||
|
|> assign(:cart_drawer_open, false)
|
||||||
|
|> preview_page_context(assigns.slug)
|
||||||
|
|
||||||
|
extra = Pages.load_block_data(page.blocks, preview)
|
||||||
|
assigns = assign(assigns, :preview, assign(preview, extra))
|
||||||
|
|
||||||
|
~H"""
|
||||||
|
<div
|
||||||
|
class="page-editor-preview themed"
|
||||||
|
data-mood={@theme_settings.mood}
|
||||||
|
data-typography={@theme_settings.typography}
|
||||||
|
data-shape={@theme_settings.shape}
|
||||||
|
data-density={@theme_settings.density}
|
||||||
|
data-grid={@theme_settings.grid_columns}
|
||||||
|
data-header={@theme_settings.header_layout}
|
||||||
|
data-sticky={to_string(@theme_settings.sticky_header)}
|
||||||
|
data-layout={@theme_settings.layout_width}
|
||||||
|
data-shadow={@theme_settings.card_shadow}
|
||||||
|
data-button-style={@theme_settings.button_style}
|
||||||
|
>
|
||||||
|
<style>
|
||||||
|
<%= Phoenix.HTML.raw(Fonts.generate_all_font_faces(&BerrypodWeb.Endpoint.static_path/1)) %>
|
||||||
|
<%= Phoenix.HTML.raw(@generated_css) %>
|
||||||
|
</style>
|
||||||
|
<BerrypodWeb.PageRenderer.render_page {@preview} />
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp preview_page_context(assigns, "pdp") do
|
||||||
|
product = List.first(assigns.preview_data.products)
|
||||||
|
option_types = Map.get(product, :option_types) || []
|
||||||
|
variants = Map.get(product, :variants) || []
|
||||||
|
|
||||||
|
{selected_options, selected_variant} =
|
||||||
|
case variants do
|
||||||
|
[first | _] -> {first.options, first}
|
||||||
|
[] -> {%{}, nil}
|
||||||
|
end
|
||||||
|
|
||||||
|
available_options =
|
||||||
|
Enum.reduce(option_types, %{}, fn opt, acc ->
|
||||||
|
values = Enum.map(opt.values, & &1.title)
|
||||||
|
Map.put(acc, opt.name, values)
|
||||||
|
end)
|
||||||
|
|
||||||
|
display_price =
|
||||||
|
if selected_variant, do: selected_variant.price, else: product.cheapest_price
|
||||||
|
|
||||||
|
assigns
|
||||||
|
|> assign(:product, product)
|
||||||
|
|> assign(:gallery_images, build_gallery_images(product))
|
||||||
|
|> assign(:option_types, option_types)
|
||||||
|
|> assign(:selected_options, selected_options)
|
||||||
|
|> assign(:available_options, available_options)
|
||||||
|
|> assign(:display_price, display_price)
|
||||||
|
|> assign(:quantity, 1)
|
||||||
|
|> assign(:option_urls, %{})
|
||||||
|
end
|
||||||
|
|
||||||
|
defp preview_page_context(assigns, "cart") do
|
||||||
|
cart_items = assigns.preview_data.cart_items
|
||||||
|
|
||||||
|
subtotal =
|
||||||
|
Enum.reduce(cart_items, 0, fn item, acc ->
|
||||||
|
acc + item.product.cheapest_price * item.quantity
|
||||||
|
end)
|
||||||
|
|
||||||
|
assigns
|
||||||
|
|> assign(:cart_page_items, cart_items)
|
||||||
|
|> assign(:cart_page_subtotal, subtotal)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp preview_page_context(assigns, "about"),
|
||||||
|
do: assign(assigns, :content_blocks, PreviewData.about_content())
|
||||||
|
|
||||||
|
defp preview_page_context(assigns, "delivery"),
|
||||||
|
do: assign(assigns, :content_blocks, PreviewData.delivery_content())
|
||||||
|
|
||||||
|
defp preview_page_context(assigns, "privacy"),
|
||||||
|
do: assign(assigns, :content_blocks, PreviewData.privacy_content())
|
||||||
|
|
||||||
|
defp preview_page_context(assigns, "terms"),
|
||||||
|
do: assign(assigns, :content_blocks, PreviewData.terms_content())
|
||||||
|
|
||||||
|
defp preview_page_context(assigns, "error") do
|
||||||
|
assign(assigns, %{
|
||||||
|
error_code: "404",
|
||||||
|
error_title: "Page Not Found",
|
||||||
|
error_description:
|
||||||
|
"Sorry, we couldn't find the page you're looking for. Perhaps you've mistyped the URL or the page has been moved."
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
defp preview_page_context(assigns, _slug), do: assigns
|
||||||
|
|
||||||
|
defp build_gallery_images(product) do
|
||||||
|
(Map.get(product, :images) || [])
|
||||||
|
|> Enum.sort_by(& &1.position)
|
||||||
|
|> Enum.map(fn img -> ProductImage.url(img, 1200) end)
|
||||||
|
|> Enum.reject(&is_nil/1)
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Block card ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
defp block_card(assigns) do
|
defp block_card(assigns) do
|
||||||
block_type = BlockTypes.get(assigns.block["type"])
|
block_type = BlockTypes.get(assigns.block["type"])
|
||||||
has_settings = has_settings?(assigns.block)
|
has_settings = has_settings?(assigns.block)
|
||||||
|
|||||||
@ -624,6 +624,83 @@ defmodule BerrypodWeb.Admin.PagesTest do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "live preview" do
|
||||||
|
setup %{conn: conn, user: user} do
|
||||||
|
%{conn: log_in_user(conn, user)}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "preview pane renders page content", %{conn: conn} do
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/admin/pages/home")
|
||||||
|
|
||||||
|
# Preview pane should be in the DOM with the themed class
|
||||||
|
assert html =~ "page-editor-preview themed"
|
||||||
|
# Should render the page via PageRenderer (hero block is on home)
|
||||||
|
assert html =~ "page-editor-preview-pane"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "toggle preview switches between edit and preview on mobile", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/admin/pages/home")
|
||||||
|
|
||||||
|
# Initially: editor visible, preview hidden on mobile
|
||||||
|
assert has_element?(view, ".page-editor-pane:not(.page-editor-pane-hidden-mobile)")
|
||||||
|
assert has_element?(view, ".page-editor-preview-hidden-mobile")
|
||||||
|
|
||||||
|
# Toggle to preview
|
||||||
|
render_click(view, "toggle_preview")
|
||||||
|
|
||||||
|
assert has_element?(view, ".page-editor-pane-hidden-mobile")
|
||||||
|
|
||||||
|
assert has_element?(
|
||||||
|
view,
|
||||||
|
".page-editor-preview-pane:not(.page-editor-preview-hidden-mobile)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Toggle back to edit
|
||||||
|
render_click(view, "toggle_preview")
|
||||||
|
|
||||||
|
assert has_element?(view, ".page-editor-pane:not(.page-editor-pane-hidden-mobile)")
|
||||||
|
assert has_element?(view, ".page-editor-preview-hidden-mobile")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "preview updates when block settings change", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/admin/pages/home")
|
||||||
|
|
||||||
|
page = Pages.get_page("home")
|
||||||
|
hero = Enum.at(page.blocks, 0)
|
||||||
|
|
||||||
|
render_click(view, "toggle_expand", %{"id" => hero["id"]})
|
||||||
|
|
||||||
|
render_change(view, "update_block_settings", %{
|
||||||
|
"block_id" => hero["id"],
|
||||||
|
"block_settings" => %{"title" => "Preview test title"}
|
||||||
|
})
|
||||||
|
|
||||||
|
# The preview should show the updated title
|
||||||
|
html = render(view)
|
||||||
|
assert html =~ "Preview test title"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "preview updates after block reorder", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/admin/pages/home")
|
||||||
|
|
||||||
|
page = Pages.get_page("home")
|
||||||
|
first_block = Enum.at(page.blocks, 0)
|
||||||
|
|
||||||
|
render_click(view, "move_down", %{"id" => first_block["id"]})
|
||||||
|
|
||||||
|
# Should still render without errors
|
||||||
|
html = render(view)
|
||||||
|
assert html =~ "page-editor-preview themed"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "preview toggle button shows in header", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/admin/pages/home")
|
||||||
|
|
||||||
|
# Toggle button should be present
|
||||||
|
assert has_element?(view, ".page-editor-toggle-preview")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp count_repeater_items(html) do
|
defp count_repeater_items(html) do
|
||||||
Regex.scan(~r/class="repeater-item"/, html) |> length()
|
Regex.scan(~r/class="repeater-item"/, html) |> length()
|
||||||
end
|
end
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user