mirror of
https://github.com/woutdp/live_svelte
synced 2026-05-24 09:28:21 +00:00
chore: added basic vite support
This commit is contained in:
parent
10c0d29ccb
commit
fffc4bdb5e
62 changed files with 1054 additions and 233 deletions
131
assets/js/live_svelte/vite_plugin.js
Normal file
131
assets/js/live_svelte/vite_plugin.js
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
/**
|
||||
* @typedef {Object} PluginOptions
|
||||
* @property {string} [path] - SSR render endpoint path (default: "/ssr_render")
|
||||
* @property {string} [entrypoint] - SSR entrypoint file (default: "./js/server.js")
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {string} path
|
||||
* @returns {"css-update" | "js-update" | null}
|
||||
*/
|
||||
function hotUpdateType(path) {
|
||||
if (path.endsWith("css")) return "css-update"
|
||||
if (path.endsWith("js")) return "js-update"
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("http").ServerResponse} res
|
||||
* @param {number} statusCode
|
||||
* @param {unknown} data
|
||||
*/
|
||||
const jsonResponse = (res, statusCode, data) => {
|
||||
res.statusCode = statusCode
|
||||
res.setHeader("Content-Type", "application/json")
|
||||
res.end(JSON.stringify(data))
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom JSON parsing middleware
|
||||
* @param {import("http").IncomingMessage & { body?: Record<string, unknown> }} req
|
||||
* @param {import("http").ServerResponse} res
|
||||
* @param {() => Promise<void>} next
|
||||
*/
|
||||
const jsonMiddleware = (req, res, next) => {
|
||||
let data = ""
|
||||
|
||||
req.on("data", chunk => {
|
||||
data += chunk
|
||||
})
|
||||
|
||||
req.on("end", () => {
|
||||
try {
|
||||
req.body = JSON.parse(data)
|
||||
next()
|
||||
} catch (error) {
|
||||
jsonResponse(res, 400, { error: "Invalid JSON" })
|
||||
}
|
||||
})
|
||||
|
||||
req.on("error", err => {
|
||||
console.error(err)
|
||||
jsonResponse(res, 500, { error: "Internal Server Error" })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* LiveSvelte Vite plugin for SSR and hot reload support.
|
||||
*
|
||||
* NOTE: Unlike LiveVue, LiveSvelte's `render()` function returns a `{head, html, css}`
|
||||
* object (not a plain HTML string). This plugin serialises that result as JSON so the
|
||||
* Elixir `LiveSvelte.SSR.ViteJS` module can decode it with `Jason.decode!/1`.
|
||||
*
|
||||
* @param {PluginOptions} [opts]
|
||||
* @returns {import("vite").Plugin}
|
||||
*/
|
||||
function liveSveltePlugin(opts = {}) {
|
||||
return {
|
||||
name: "live-svelte",
|
||||
handleHotUpdate({ file, modules, server, timestamp }) {
|
||||
if (file.match(/\.(heex|ex)$/)) {
|
||||
const invalidatedModules = new Set()
|
||||
for (const mod of modules) {
|
||||
server.moduleGraph.invalidateModule(mod, invalidatedModules, timestamp, true)
|
||||
}
|
||||
|
||||
const updates = Array.from(invalidatedModules).flatMap(m => {
|
||||
const { file } = m
|
||||
|
||||
if (!file) return []
|
||||
|
||||
const updateType = hotUpdateType(file)
|
||||
|
||||
if (!updateType) return []
|
||||
|
||||
return {
|
||||
type: updateType,
|
||||
path: m.url,
|
||||
acceptedPath: m.url,
|
||||
timestamp: timestamp,
|
||||
}
|
||||
})
|
||||
|
||||
server.ws.send({
|
||||
type: "update",
|
||||
updates,
|
||||
})
|
||||
|
||||
return []
|
||||
}
|
||||
},
|
||||
configureServer(server) {
|
||||
process.stdin.on("close", () => process.exit(0))
|
||||
process.stdin.resume()
|
||||
|
||||
const path = opts.path || "/ssr_render"
|
||||
const entrypoint = opts.entrypoint || "./js/server.js"
|
||||
server.middlewares.use(function liveSvelteMiddleware(req, res, next) {
|
||||
if (req.method == "POST" && req.url?.split("?", 1)[0] === path) {
|
||||
jsonMiddleware(req, res, async () => {
|
||||
try {
|
||||
const render = (await server.ssrLoadModule(entrypoint)).render
|
||||
const result = await render(req.body?.name, req.body?.props, req.body?.slots)
|
||||
// LiveSvelte render returns {head, html, css} — must JSON-encode for Elixir decoder
|
||||
res.setHeader("Content-Type", "application/json")
|
||||
res.end(JSON.stringify(result))
|
||||
} catch (e) {
|
||||
e instanceof Error && server.ssrFixStacktrace(e)
|
||||
jsonResponse(res, 500, { error: e })
|
||||
}
|
||||
})
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default liveSveltePlugin
|
||||
67
example_project/assets/js/app.vite.js
Normal file
67
example_project/assets/js/app.vite.js
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
// Vite dev server entry point for HMR development.
|
||||
// Replaces the esbuild-plugin-import-glob syntax in app.js with Vite's native
|
||||
// import.meta.glob. The esbuild build (app.js) remains unchanged for production.
|
||||
|
||||
import "phoenix_html"
|
||||
import {Socket} from "phoenix"
|
||||
import {LiveSocket} from "phoenix_live_view"
|
||||
import topbar from "../vendor/topbar"
|
||||
// TODO(Epic 9): remove createLiveJsonHooks once live_json dependency is removed
|
||||
import {createLiveJsonHooks} from "live_json"
|
||||
import {getHooks} from "live_svelte"
|
||||
|
||||
// Vite-native glob (replaces `import * as Components from "../svelte/**/*.svelte"`)
|
||||
const Components = import.meta.glob('../svelte/**/*.svelte', { eager: true })
|
||||
|
||||
function formatPayload(str) {
|
||||
if (!str || str === "—") return "—"
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(str), null, 2)
|
||||
} catch {
|
||||
return str
|
||||
}
|
||||
}
|
||||
|
||||
const PropsDiffPayloadDisplay = {
|
||||
mounted() {
|
||||
this.updateDisplays()
|
||||
const root = this.el
|
||||
const diffOnEl = root.querySelector("[data-name='PropsDiffDemo'][data-use-diff='true']")
|
||||
const diffOffEl = root.querySelector("[data-name='PropsDiffDemo'][data-use-diff='false']")
|
||||
const observer = new MutationObserver(() => this.updateDisplays())
|
||||
if (diffOnEl) observer.observe(diffOnEl, {attributes: true, attributeFilter: ["data-props"]})
|
||||
if (diffOffEl) observer.observe(diffOffEl, {attributes: true, attributeFilter: ["data-props"]})
|
||||
this._observer = observer
|
||||
},
|
||||
updated() {
|
||||
this.updateDisplays()
|
||||
},
|
||||
destroyed() {
|
||||
this._observer?.disconnect()
|
||||
},
|
||||
updateDisplays() {
|
||||
const root = this.el
|
||||
const diffOnEl = root.querySelector("[data-name='PropsDiffDemo'][data-use-diff='true']")
|
||||
const diffOffEl = root.querySelector("[data-name='PropsDiffDemo'][data-use-diff='false']")
|
||||
const preOn = root.querySelector("#payload-display-diff-on")
|
||||
const preOff = root.querySelector("#payload-display-diff-off")
|
||||
if (preOn) preOn.textContent = formatPayload(diffOnEl?.getAttribute("data-props") ?? "—")
|
||||
if (preOff) preOff.textContent = formatPayload(diffOffEl?.getAttribute("data-props") ?? "—")
|
||||
},
|
||||
}
|
||||
|
||||
const Hooks = {
|
||||
...createLiveJsonHooks(),
|
||||
...getHooks(Components),
|
||||
PropsDiffPayloadDisplay,
|
||||
}
|
||||
|
||||
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
||||
let liveSocket = new LiveSocket("/live", Socket, {hooks: Hooks, params: {_csrf_token: csrfToken}})
|
||||
|
||||
topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
|
||||
window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
|
||||
window.addEventListener("phx:page-loading-stop", _info => topbar.hide())
|
||||
|
||||
liveSocket.connect()
|
||||
window.liveSocket = liveSocket
|
||||
9
example_project/assets/js/server.vite.js
Normal file
9
example_project/assets/js/server.vite.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// Vite SSR entry point for liveSveltePlugin's /ssr_render endpoint.
|
||||
// Uses Vite-native import.meta.glob instead of the esbuild-plugin-import-glob
|
||||
// syntax used by server.js. Configured via vite.config.js:
|
||||
// liveSveltePlugin({ entrypoint: './js/server.vite.js' })
|
||||
import { getRender } from "live_svelte"
|
||||
|
||||
const Components = import.meta.glob("../svelte/**/*.svelte", { eager: true })
|
||||
|
||||
export const render = getRender(Components)
|
||||
|
|
@ -3,7 +3,9 @@
|
|||
"@types/lodash": "^4.14.192",
|
||||
"daisyui": "^5.0.0",
|
||||
"dynamic-marquee": "^2.6.2",
|
||||
"esbuild": "^0.24.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"esbuild": "^0.24.0",
|
||||
"vite": "^6.0.0",
|
||||
"esbuild-plugin-import-glob": "^0.1.1",
|
||||
"esbuild-svelte": "^0.9.0",
|
||||
"lodash": "^4.17.21",
|
||||
|
|
|
|||
11
example_project/assets/vite.config.js
Normal file
11
example_project/assets/vite.config.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { defineConfig } from "vite"
|
||||
import { svelte } from "@sveltejs/vite-plugin-svelte"
|
||||
import liveSveltePlugin from "live_svelte/vitePlugin"
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte(), liveSveltePlugin({ entrypoint: "./js/server.vite.js" })],
|
||||
server: {
|
||||
host: "localhost",
|
||||
port: 5173,
|
||||
},
|
||||
})
|
||||
|
|
@ -62,6 +62,21 @@ config :example, ExampleWeb.Endpoint,
|
|||
# Enable dev routes for dashboard and mailbox
|
||||
config :example, dev_routes: true
|
||||
|
||||
# To use Vite dev server for HMR + SSR during development:
|
||||
#
|
||||
# 1. Install Vite deps (one-time):
|
||||
# cd assets && npm install
|
||||
#
|
||||
# 2. Start Vite dev server (in a separate terminal):
|
||||
# cd assets && npx vite
|
||||
#
|
||||
# 3. Enable Vite in config (uncomment below):
|
||||
# config :live_svelte, ssr_module: LiveSvelte.SSR.ViteJS
|
||||
# config :live_svelte, vite_host: "http://localhost:5173"
|
||||
#
|
||||
# With :vite_host set, LiveSvelte.Reload.vite_assets/1 in root.html.heex
|
||||
# automatically serves assets from Vite (with HMR) instead of compiled files.
|
||||
|
||||
# Do not include metadata nor timestamps in development logs
|
||||
config :logger, :console, format: "[$level] $message\n"
|
||||
|
||||
|
|
|
|||
|
|
@ -84,17 +84,17 @@ defmodule ExampleWeb.CoreComponents do
|
|||
<div id={"#{@id}-content"}>
|
||||
<header :if={@title != []}>
|
||||
<h1 id={"#{@id}-title"} class="text-lg font-semibold leading-8 text-zinc-800">
|
||||
<%= render_slot(@title) %>
|
||||
{render_slot(@title)}
|
||||
</h1>
|
||||
<p
|
||||
:if={@subtitle != []}
|
||||
id={"#{@id}-description"}
|
||||
class="mt-2 text-sm leading-6 text-zinc-600"
|
||||
>
|
||||
<%= render_slot(@subtitle) %>
|
||||
{render_slot(@subtitle)}
|
||||
</p>
|
||||
</header>
|
||||
<%= render_slot(@inner_block) %>
|
||||
{render_slot(@inner_block)}
|
||||
<div :if={@confirm != [] or @cancel != []} class="ml-6 mb-4 flex items-center gap-5">
|
||||
<.button
|
||||
:for={confirm <- @confirm}
|
||||
|
|
@ -103,14 +103,14 @@ defmodule ExampleWeb.CoreComponents do
|
|||
phx-disable-with
|
||||
class="py-2 px-3"
|
||||
>
|
||||
<%= render_slot(confirm) %>
|
||||
{render_slot(confirm)}
|
||||
</.button>
|
||||
<.link
|
||||
:for={cancel <- @cancel}
|
||||
phx-click={hide_modal(@on_cancel, @id)}
|
||||
class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
|
||||
>
|
||||
<%= render_slot(cancel) %>
|
||||
{render_slot(cancel)}
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -158,9 +158,9 @@ defmodule ExampleWeb.CoreComponents do
|
|||
<p :if={@title} class="flex items-center gap-1.5 text-[0.8125rem] font-semibold leading-6">
|
||||
<.icon :if={@kind == :info} name="hero-information-circle-mini" class="w-4 h-4" />
|
||||
<.icon :if={@kind == :error} name="hero-exclamation-circle-mini" class="w-4 h-4" />
|
||||
<%= @title %>
|
||||
{@title}
|
||||
</p>
|
||||
<p class="mt-2 text-[0.8125rem] leading-5"><%= msg %></p>
|
||||
<p class="mt-2 text-[0.8125rem] leading-5">{msg}</p>
|
||||
<button
|
||||
:if={@close}
|
||||
type="button"
|
||||
|
|
@ -227,9 +227,9 @@ defmodule ExampleWeb.CoreComponents do
|
|||
~H"""
|
||||
<.form :let={f} for={@for} as={@as} {@rest}>
|
||||
<div class="space-y-8 bg-base-100 mt-10">
|
||||
<%= render_slot(@inner_block, f) %>
|
||||
{render_slot(@inner_block, f)}
|
||||
<div :for={action <- @actions} class="mt-2 flex items-center justify-between gap-6">
|
||||
<%= render_slot(action, f) %>
|
||||
{render_slot(action, f)}
|
||||
</div>
|
||||
</div>
|
||||
</.form>
|
||||
|
|
@ -260,7 +260,7 @@ defmodule ExampleWeb.CoreComponents do
|
|||
]}
|
||||
{@rest}
|
||||
>
|
||||
<%= render_slot(@inner_block) %>
|
||||
{render_slot(@inner_block)}
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
|
|
@ -325,9 +325,9 @@ defmodule ExampleWeb.CoreComponents do
|
|||
class="checkbox checkbox-sm"
|
||||
{@rest}
|
||||
/>
|
||||
<%= @label %>
|
||||
{@label}
|
||||
</label>
|
||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
||||
<.error :for={msg <- @errors}>{msg}</.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
|
@ -335,7 +335,7 @@ defmodule ExampleWeb.CoreComponents do
|
|||
def input(%{type: "select"} = assigns) do
|
||||
~H"""
|
||||
<div phx-feedback-for={@name}>
|
||||
<.label for={@id}><%= @label %></.label>
|
||||
<.label for={@id}>{@label}</.label>
|
||||
<select
|
||||
id={@id}
|
||||
name={@name}
|
||||
|
|
@ -343,10 +343,10 @@ defmodule ExampleWeb.CoreComponents do
|
|||
multiple={@multiple}
|
||||
{@rest}
|
||||
>
|
||||
<option :if={@prompt} value=""><%= @prompt %></option>
|
||||
<%= Phoenix.HTML.Form.options_for_select(@options, @value) %>
|
||||
<option :if={@prompt} value="">{@prompt}</option>
|
||||
{Phoenix.HTML.Form.options_for_select(@options, @value)}
|
||||
</select>
|
||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
||||
<.error :for={msg <- @errors}>{msg}</.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
|
@ -354,7 +354,7 @@ defmodule ExampleWeb.CoreComponents do
|
|||
def input(%{type: "textarea"} = assigns) do
|
||||
~H"""
|
||||
<div phx-feedback-for={@name}>
|
||||
<.label for={@id}><%= @label %></.label>
|
||||
<.label for={@id}>{@label}</.label>
|
||||
<textarea
|
||||
id={@id || @name}
|
||||
name={@name}
|
||||
|
|
@ -364,7 +364,7 @@ defmodule ExampleWeb.CoreComponents do
|
|||
]}
|
||||
{@rest}
|
||||
><%= Phoenix.HTML.Form.normalize_value("textarea", @value) %></textarea>
|
||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
||||
<.error :for={msg <- @errors}>{msg}</.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
|
@ -372,7 +372,7 @@ defmodule ExampleWeb.CoreComponents do
|
|||
def input(assigns) do
|
||||
~H"""
|
||||
<div phx-feedback-for={@name}>
|
||||
<.label for={@id}><%= @label %></.label>
|
||||
<.label for={@id}>{@label}</.label>
|
||||
<input
|
||||
type={@type}
|
||||
name={@name}
|
||||
|
|
@ -384,7 +384,7 @@ defmodule ExampleWeb.CoreComponents do
|
|||
]}
|
||||
{@rest}
|
||||
/>
|
||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
||||
<.error :for={msg <- @errors}>{msg}</.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
|
@ -398,7 +398,7 @@ defmodule ExampleWeb.CoreComponents do
|
|||
def label(assigns) do
|
||||
~H"""
|
||||
<label for={@for} class="block text-sm font-semibold leading-6 text-zinc-800">
|
||||
<%= render_slot(@inner_block) %>
|
||||
{render_slot(@inner_block)}
|
||||
</label>
|
||||
"""
|
||||
end
|
||||
|
|
@ -412,7 +412,7 @@ defmodule ExampleWeb.CoreComponents do
|
|||
~H"""
|
||||
<p class="phx-no-feedback:hidden mt-3 flex gap-3 text-sm leading-6 text-error">
|
||||
<.icon name="hero-exclamation-circle-mini" class="mt-0.5 w-5 h-5 flex-none" />
|
||||
<%= render_slot(@inner_block) %>
|
||||
{render_slot(@inner_block)}
|
||||
</p>
|
||||
"""
|
||||
end
|
||||
|
|
@ -431,13 +431,13 @@ defmodule ExampleWeb.CoreComponents do
|
|||
<header class={[@actions != [] && "flex items-center justify-between gap-6", @class]}>
|
||||
<div>
|
||||
<h1 class="text-lg font-semibold leading-8 text-zinc-800">
|
||||
<%= render_slot(@inner_block) %>
|
||||
{render_slot(@inner_block)}
|
||||
</h1>
|
||||
<p :if={@subtitle != []} class="mt-2 text-sm leading-6 text-zinc-600">
|
||||
<%= render_slot(@subtitle) %>
|
||||
{render_slot(@subtitle)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-none"><%= render_slot(@actions) %></div>
|
||||
<div class="flex-none">{render_slot(@actions)}</div>
|
||||
</header>
|
||||
"""
|
||||
end
|
||||
|
|
@ -478,8 +478,8 @@ defmodule ExampleWeb.CoreComponents do
|
|||
<table class="mt-11 w-[40rem] sm:w-full">
|
||||
<thead class="text-left text-[0.8125rem] leading-6 text-zinc-500">
|
||||
<tr>
|
||||
<th :for={col <- @col} class="p-0 pb-4 pr-6 font-normal"><%= col[:label] %></th>
|
||||
<th class="relative p-0 pb-4"><span class="sr-only"><%= gettext("Actions") %></span></th>
|
||||
<th :for={col <- @col} class="p-0 pb-4 pr-6 font-normal">{col[:label]}</th>
|
||||
<th class="relative p-0 pb-4"><span class="sr-only">{gettext("Actions")}</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
|
|
@ -496,7 +496,7 @@ defmodule ExampleWeb.CoreComponents do
|
|||
<div class="block py-4 pr-6">
|
||||
<span class="absolute -inset-y-px right-0 -left-4 group-hover:bg-zinc-50 sm:rounded-l-xl" />
|
||||
<span class={["relative", i == 0 && "font-semibold text-zinc-900"]}>
|
||||
<%= render_slot(col, @row_item.(row)) %>
|
||||
{render_slot(col, @row_item.(row))}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
|
@ -507,7 +507,7 @@ defmodule ExampleWeb.CoreComponents do
|
|||
:for={action <- @action}
|
||||
class="relative ml-4 font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
|
||||
>
|
||||
<%= render_slot(action, @row_item.(row)) %>
|
||||
{render_slot(action, @row_item.(row))}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
|
@ -537,8 +537,8 @@ defmodule ExampleWeb.CoreComponents do
|
|||
<div class="mt-14">
|
||||
<dl class="-my-4 divide-y divide-zinc-100">
|
||||
<div :for={item <- @item} class="flex gap-4 py-4 sm:gap-8">
|
||||
<dt class="w-1/4 flex-none text-[0.8125rem] leading-6 text-zinc-500"><%= item.title %></dt>
|
||||
<dd class="text-sm leading-6 text-zinc-700"><%= render_slot(item) %></dd>
|
||||
<dt class="w-1/4 flex-none text-[0.8125rem] leading-6 text-zinc-500">{item.title}</dt>
|
||||
<dd class="text-sm leading-6 text-zinc-700">{render_slot(item)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
|
@ -563,7 +563,7 @@ defmodule ExampleWeb.CoreComponents do
|
|||
class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
|
||||
>
|
||||
<.icon name="hero-arrow-left-solid" class="w-3 h-3" />
|
||||
<%= render_slot(@inner_block) %>
|
||||
{render_slot(@inner_block)}
|
||||
</.link>
|
||||
</div>
|
||||
"""
|
||||
|
|
@ -679,5 +679,4 @@ defmodule ExampleWeb.CoreComponents do
|
|||
def translate_errors(errors, field) when is_list(errors) do
|
||||
for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
|
||||
end
|
||||
|
||||
end
|
||||
|
|
|
|||
|
|
@ -77,12 +77,15 @@ defmodule ExampleWeb.Layouts do
|
|||
<ul role="list" class="flex flex-1 flex-col gap-y-7">
|
||||
<li :for={group <- @nav_groups}>
|
||||
<div class="text-xs font-semibold leading-6 text-base-content/40 uppercase tracking-wider">
|
||||
<%= group.label %>
|
||||
{group.label}
|
||||
</div>
|
||||
<ul role="list" class="-mx-2 mt-2 space-y-1">
|
||||
<li :for={link <- group.links}>
|
||||
<a href={link.to} class="block px-3 py-2 rounded-md text-sm font-medium text-base-content hover:bg-base-200">
|
||||
<%= link.label %>
|
||||
<a
|
||||
href={link.to}
|
||||
class="block px-3 py-2 rounded-md text-sm font-medium text-base-content hover:bg-base-200"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
|
@ -98,13 +101,20 @@ defmodule ExampleWeb.Layouts do
|
|||
~H"""
|
||||
<nav class="hidden lg:flex lg:items-center lg:gap-1">
|
||||
<div :for={group <- @nav_groups} class="relative group">
|
||||
<button type="button" class="px-3 py-2 text-sm font-medium text-base-content rounded-md hover:bg-base-200">
|
||||
<%= group.label %>
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-2 text-sm font-medium text-base-content rounded-md hover:bg-base-200"
|
||||
>
|
||||
{group.label}
|
||||
</button>
|
||||
<div class="absolute left-0 mt-1 w-48 bg-base-100 rounded-box shadow-lg border border-base-300 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-150 z-50">
|
||||
<div class="py-1">
|
||||
<a :for={link <- group.links} href={link.to} class="block px-4 py-2 text-sm text-base-content hover:bg-base-200">
|
||||
<%= link.label %>
|
||||
<a
|
||||
:for={link <- group.links}
|
||||
href={link.to}
|
||||
class="block px-4 py-2 text-sm text-base-content hover:bg-base-200"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,11 @@
|
|||
<%!-- Logo and mobile menu button --%>
|
||||
<div class="flex items-center gap-4">
|
||||
<%!-- Mobile menu button --%>
|
||||
<label for="mobile-drawer" class="lg:hidden -m-2.5 p-2.5 text-zinc-700 cursor-pointer" aria-label="Open sidebar">
|
||||
<label
|
||||
for="mobile-drawer"
|
||||
class="lg:hidden -m-2.5 p-2.5 text-zinc-700 cursor-pointer"
|
||||
aria-label="Open sidebar"
|
||||
>
|
||||
<.icon name="hero-bars-3" class="h-6 w-6" />
|
||||
</label>
|
||||
|
||||
|
|
@ -45,7 +49,7 @@
|
|||
</header>
|
||||
|
||||
<main class="p-4 sm:py-8 sm:px-6 lg:px-8 min-h-[calc(100vh-65px)]">
|
||||
<%= @inner_content %>
|
||||
{@inner_content}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -5,13 +5,15 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="csrf-token" content={get_csrf_token()} />
|
||||
<.live_title suffix=" · Phoenix Framework">
|
||||
<%= assigns[:page_title] || "Example" %>
|
||||
{assigns[:page_title] || "Example"}
|
||||
</.live_title>
|
||||
<link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
|
||||
<script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
|
||||
</script>
|
||||
<LiveSvelte.Reload.vite_assets assets={["/js/app.vite.js"]}>
|
||||
<link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
|
||||
<script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
|
||||
</script>
|
||||
</LiveSvelte.Reload.vite_assets>
|
||||
</head>
|
||||
<body class="bg-base-200 antialiased" data-theme="light">
|
||||
<%= @inner_content %>
|
||||
{@inner_content}
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -33,7 +33,10 @@ defmodule ExampleWeb.LiveChat do
|
|||
autocomplete="name"
|
||||
aria-label="Your name"
|
||||
/>
|
||||
<button type="submit" class="btn btn-sm bg-brand text-white border-0 hover:opacity-90 shrink-0">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-sm bg-brand text-white border-0 hover:opacity-90 shrink-0"
|
||||
>
|
||||
Join
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -41,11 +44,7 @@ defmodule ExampleWeb.LiveChat do
|
|||
</div>
|
||||
</form>
|
||||
<div :if={@name} class="w-full flex justify-center">
|
||||
<.Chat
|
||||
messages={@messages}
|
||||
name={@name}
|
||||
socket={@socket}
|
||||
/>
|
||||
<.Chat messages={@messages} name={@name} socket={@socket} />
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -12,12 +12,17 @@ defmodule ExampleWeb.LiveClientSideLoading do
|
|||
</p>
|
||||
|
||||
<div class="flex flex-col sm:flex-row flex-wrap justify-center gap-6 w-full max-w-3xl">
|
||||
<section data-testid="client-side-loading-client-section" class="card bg-base-100 shadow-md border border-base-300/50 overflow-hidden w-full max-w-sm">
|
||||
<section
|
||||
data-testid="client-side-loading-client-section"
|
||||
class="card bg-base-100 shadow-md border border-base-300/50 overflow-hidden w-full max-w-sm"
|
||||
>
|
||||
<div class="card-body gap-4 p-5">
|
||||
<span class="badge badge-ghost badge-sm font-medium text-base-content/70 w-fit">
|
||||
Client side
|
||||
</span>
|
||||
<p class="text-xs text-base-content/50">Recommended: no SSR, loading slot shown until hydrated.</p>
|
||||
<p class="text-xs text-base-content/50">
|
||||
Recommended: no SSR, loading slot shown until hydrated.
|
||||
</p>
|
||||
<.svelte name="ClientSideLoading" ssr={false}>
|
||||
<:loading>
|
||||
<div class="flex items-center gap-2 py-4">
|
||||
|
|
@ -29,7 +34,10 @@ defmodule ExampleWeb.LiveClientSideLoading do
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section data-testid="client-side-loading-server-section" class="card bg-base-100 shadow-md border border-base-300/50 overflow-hidden w-full max-w-sm border-warning/50">
|
||||
<section
|
||||
data-testid="client-side-loading-server-section"
|
||||
class="card bg-base-100 shadow-md border border-base-300/50 overflow-hidden w-full max-w-sm border-warning/50"
|
||||
>
|
||||
<div class="card-body gap-4 p-5">
|
||||
<span class="badge badge-warning badge-sm font-medium w-fit">
|
||||
Server side (avoid)
|
||||
|
|
|
|||
|
|
@ -23,14 +23,13 @@ defmodule ExampleWeb.LiveIdListDiff do
|
|||
~H"""
|
||||
<div class="min-h-screen bg-base-200/40 py-8 px-4">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<h1
|
||||
class="text-center text-2xl font-light my-4"
|
||||
data-testid="id-list-diff-title"
|
||||
>
|
||||
<h1 class="text-center text-2xl font-light my-4" data-testid="id-list-diff-title">
|
||||
ID-Based List Diffing Demo
|
||||
</h1>
|
||||
<p class="text-sm text-base-content/50 mb-6 text-center">
|
||||
Inserts and deletes produce minimal JSON Patch ops because list items carry an <code>:id</code> field.
|
||||
Inserts and deletes produce minimal JSON Patch ops because list items carry an
|
||||
<code>:id</code>
|
||||
field.
|
||||
</p>
|
||||
|
||||
<div class="card bg-base-100 shadow-lg border border-base-300/50 mb-6">
|
||||
|
|
@ -39,18 +38,10 @@ defmodule ExampleWeb.LiveIdListDiff do
|
|||
LiveView controls
|
||||
</span>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button
|
||||
data-testid="insert-item"
|
||||
class="btn btn-sm btn-primary"
|
||||
phx-click="insert_item"
|
||||
>
|
||||
<button data-testid="insert-item" class="btn btn-sm btn-primary" phx-click="insert_item">
|
||||
Insert Item
|
||||
</button>
|
||||
<button
|
||||
data-testid="delete-first"
|
||||
class="btn btn-sm btn-error"
|
||||
phx-click="delete_first"
|
||||
>
|
||||
<button data-testid="delete-first" class="btn btn-sm btn-error" phx-click="delete_first">
|
||||
Delete First
|
||||
</button>
|
||||
<button
|
||||
|
|
@ -62,7 +53,7 @@ defmodule ExampleWeb.LiveIdListDiff do
|
|||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-base-content/50">
|
||||
Item count: <%= length(@items) %>
|
||||
Item count: {length(@items)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -91,8 +82,12 @@ defmodule ExampleWeb.LiveIdListDiff do
|
|||
|
||||
def handle_event("move_last_to_top", _params, socket) do
|
||||
case socket.assigns.items do
|
||||
[] -> {:noreply, socket}
|
||||
[_] -> {:noreply, socket}
|
||||
[] ->
|
||||
{:noreply, socket}
|
||||
|
||||
[_] ->
|
||||
{:noreply, socket}
|
||||
|
||||
items ->
|
||||
last = List.last(items)
|
||||
rest = Enum.take(items, length(items) - 1)
|
||||
|
|
|
|||
|
|
@ -17,7 +17,11 @@ defmodule ExampleWeb.LiveJson do
|
|||
<span class="badge badge-outline badge-sm font-medium text-base-content/70 w-fit">
|
||||
SSR
|
||||
</span>
|
||||
<.svelte name="LiveJson" live_json_props={%{big_data_set: @ljbig_data_set}} socket={@socket} />
|
||||
<.svelte
|
||||
name="LiveJson"
|
||||
live_json_props={%{big_data_set: @ljbig_data_set}}
|
||||
socket={@socket}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<section class="card bg-base-100 shadow-md border border-base-300/50 overflow-hidden w-full max-w-sm">
|
||||
|
|
|
|||
|
|
@ -25,10 +25,12 @@ defmodule ExampleWeb.LiveNavigation do
|
|||
<div class="flex flex-col justify-center items-center gap-6 p-6">
|
||||
<h2 class="text-center text-2xl font-light my-4">Navigation (useLiveNavigation)</h2>
|
||||
<p class="text-sm text-base-content/50 text-center max-w-md">
|
||||
Client-side navigation with <code>patch()</code> and <code>navigate()</code>
|
||||
from Svelte without full page reloads, plus the <code>Link</code> component.
|
||||
Client-side navigation with <code>patch()</code>
|
||||
and <code>navigate()</code>
|
||||
from Svelte without full page reloads, plus the <code>Link</code>
|
||||
component.
|
||||
</p>
|
||||
<.svelte name="Navigation" props={%{page: @page, query: @query}} socket={@socket} ssr={false} />
|
||||
<.svelte name="Navigation" props={%{page: @page, query: @query}} socket={@socket} />
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
|
|
|||
|
|
@ -33,11 +33,13 @@ defmodule ExampleWeb.LiveNotesOtp do
|
|||
</p>
|
||||
<.svelte
|
||||
name="NotesApp"
|
||||
props={%{
|
||||
notes: @notes,
|
||||
encoder: "OTP",
|
||||
info: @info
|
||||
}}
|
||||
props={
|
||||
%{
|
||||
notes: @notes,
|
||||
encoder: "OTP",
|
||||
info: @info
|
||||
}
|
||||
}
|
||||
socket={@socket}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -22,16 +22,21 @@ defmodule ExampleWeb.LivePlusMinus do
|
|||
aria-label={"Decrease by #{@amount}"}
|
||||
data-testid="live-plus-minus-minus"
|
||||
>
|
||||
-<%= @amount %>
|
||||
-{@amount}
|
||||
</button>
|
||||
<span class="text-3xl font-bold tabular-nums text-brand min-w-[3rem] text-center" data-testid="live-plus-minus-value"><%= @number %></span>
|
||||
<span
|
||||
class="text-3xl font-bold tabular-nums text-brand min-w-[3rem] text-center"
|
||||
data-testid="live-plus-minus-value"
|
||||
>
|
||||
{@number}
|
||||
</span>
|
||||
<button
|
||||
class="btn btn-square btn-sm btn-success border-0"
|
||||
phx-click="add"
|
||||
aria-label={"Increase by #{@amount}"}
|
||||
data-testid="live-plus-minus-plus"
|
||||
>
|
||||
+<%= @amount %>
|
||||
+{@amount}
|
||||
</button>
|
||||
</div>
|
||||
<label class="flex flex-col gap-1.5 mx-auto">
|
||||
|
|
@ -68,14 +73,17 @@ defmodule ExampleWeb.LivePlusMinus do
|
|||
|
||||
def handle_event("update_amount", %{"value" => value}, socket) do
|
||||
amount_str = extract_amount_value(value)
|
||||
amount = if is_binary(amount_str) and amount_str != "" do
|
||||
case Integer.parse(amount_str) do
|
||||
{n, _} when n >= 1 -> n
|
||||
_ -> socket.assigns.amount
|
||||
|
||||
amount =
|
||||
if is_binary(amount_str) and amount_str != "" do
|
||||
case Integer.parse(amount_str) do
|
||||
{n, _} when n >= 1 -> n
|
||||
_ -> socket.assigns.amount
|
||||
end
|
||||
else
|
||||
socket.assigns.amount
|
||||
end
|
||||
else
|
||||
socket.assigns.amount
|
||||
end
|
||||
|
||||
{:noreply, assign(socket, :amount, amount)}
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -22,8 +22,9 @@ defmodule ExampleWeb.LivePropsDiff do
|
|||
Props Diff Demo
|
||||
</h1>
|
||||
<p class="text-sm text-base-content/50 mb-6 text-center">
|
||||
Click a button, then compare the two payloads below: <strong>diff on</strong> sends only changed keys,
|
||||
<strong>diff off</strong> sends the full object every time.
|
||||
Click a button, then compare the two payloads below: <strong>diff on</strong>
|
||||
sends only changed keys, <strong>diff off</strong>
|
||||
sends the full object every time.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-6" id="props-diff-demo-root" phx-hook="PropsDiffPayloadDisplay">
|
||||
|
|
@ -47,16 +48,12 @@ defmodule ExampleWeb.LivePropsDiff do
|
|||
>
|
||||
Increment B
|
||||
</button>
|
||||
<button
|
||||
data-testid="props-diff-inc-c"
|
||||
class="btn btn-sm btn-accent"
|
||||
phx-click="inc_c"
|
||||
>
|
||||
<button data-testid="props-diff-inc-c" class="btn btn-sm btn-accent" phx-click="inc_c">
|
||||
Increment C
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-base-content/50">
|
||||
Server state: A=<%= @a %>, B=<%= @b %>, C=<%= @c %>
|
||||
Server state: A={@a}, B={@b}, C={@c}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -92,20 +89,35 @@ defmodule ExampleWeb.LivePropsDiff do
|
|||
</section>
|
||||
</div>
|
||||
|
||||
<section class="card bg-base-100 shadow-lg border border-base-300/50" aria-label="Payload in DOM">
|
||||
<section
|
||||
class="card bg-base-100 shadow-lg border border-base-300/50"
|
||||
aria-label="Payload in DOM"
|
||||
>
|
||||
<div class="card-body gap-2">
|
||||
<h2 class="text-lg font-semibold text-base-content">Payload in DOM (<code>data-props</code>)</h2>
|
||||
<h2 class="text-lg font-semibold text-base-content">
|
||||
Payload in DOM (<code>data-props</code>)
|
||||
</h2>
|
||||
<p class="text-xs text-base-content/50 mb-2">
|
||||
After each update, the diff-on component receives only changed keys; the diff-off component receives the full object.
|
||||
</p>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-base-content/70 mb-1">diff on (only changed keys)</p>
|
||||
<pre id="payload-display-diff-on" class="bg-base-200 text-xs p-3 rounded-lg overflow-x-auto min-h-[4rem]" data-testid="payload-diff-on">—</pre>
|
||||
<p class="text-xs font-medium text-base-content/70 mb-1">
|
||||
diff on (only changed keys)
|
||||
</p>
|
||||
<pre
|
||||
id="payload-display-diff-on"
|
||||
class="bg-base-200 text-xs p-3 rounded-lg overflow-x-auto min-h-[4rem]"
|
||||
data-testid="payload-diff-on"
|
||||
>—</pre>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-base-content/70 mb-1">diff off (full object)</p>
|
||||
<pre id="payload-display-diff-off" class="bg-base-200 text-xs p-3 rounded-lg overflow-x-auto min-h-[4rem]" data-testid="payload-diff-off">—</pre>
|
||||
<pre
|
||||
id="payload-display-diff-off"
|
||||
class="bg-base-200 text-xs p-3 rounded-lg overflow-x-auto min-h-[4rem]"
|
||||
data-testid="payload-diff-off"
|
||||
>—</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -19,8 +19,17 @@ defmodule ExampleWeb.LiveSimpleCounter do
|
|||
LiveView (native)
|
||||
</span>
|
||||
<div class="flex flex-row items-center justify-center gap-6 py-2">
|
||||
<span data-testid="live-simple-counter-value" class="text-4xl font-bold tabular-nums text-brand"><%= @number %></span>
|
||||
<button data-testid="live-simple-counter-increment" class="btn btn-sm bg-brand text-white border-0 hover:opacity-90" phx-click="increment">
|
||||
<span
|
||||
data-testid="live-simple-counter-value"
|
||||
class="text-4xl font-bold tabular-nums text-brand"
|
||||
>
|
||||
{@number}
|
||||
</span>
|
||||
<button
|
||||
data-testid="live-simple-counter-increment"
|
||||
class="btn btn-sm bg-brand text-white border-0 hover:opacity-90"
|
||||
phx-click="increment"
|
||||
>
|
||||
+1
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -35,11 +44,19 @@ defmodule ExampleWeb.LiveSimpleCounter do
|
|||
<div class="flex flex-wrap gap-6 justify-center py-4">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<span class="text-xs text-base-content/50">Component 1</span>
|
||||
<.svelte name="SimpleCounter" props={%{number: @number, initialClientValue: @initial_client_value}} socket={@socket} />
|
||||
<.svelte
|
||||
name="SimpleCounter"
|
||||
props={%{number: @number, initialClientValue: @initial_client_value}}
|
||||
socket={@socket}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<span class="text-xs text-base-content/50">Component 2</span>
|
||||
<.svelte name="SimpleCounter" props={%{number: @number, initialClientValue: @initial_client_value}} socket={@socket} />
|
||||
<.svelte
|
||||
name="SimpleCounter"
|
||||
props={%{number: @number, initialClientValue: @initial_client_value}}
|
||||
socket={@socket}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,14 +12,25 @@ defmodule ExampleWeb.LiveSlotsDynamic do
|
|||
</p>
|
||||
<.svelte name="Slots" socket={@socket}>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<button data-testid="slots-dynamic-increment" phx-click="increase" class="btn btn-sm bg-brand text-white border-0 hover:opacity-90">
|
||||
<button
|
||||
data-testid="slots-dynamic-increment"
|
||||
phx-click="increase"
|
||||
class="btn btn-sm bg-brand text-white border-0 hover:opacity-90"
|
||||
>
|
||||
Increment the number
|
||||
</button>
|
||||
<span data-testid="slots-dynamic-number" class="text-2xl font-bold tabular-nums text-brand"><%= @number %></span>
|
||||
<span data-testid="slots-dynamic-number" class="text-2xl font-bold tabular-nums text-brand">
|
||||
{@number}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<:subtitle>
|
||||
<span data-testid="slots-dynamic-subtitle-number" class="text-xl font-semibold tabular-nums text-brand"><%= @number %></span>
|
||||
<span
|
||||
data-testid="slots-dynamic-subtitle-number"
|
||||
class="text-xl font-semibold tabular-nums text-brand"
|
||||
>
|
||||
{@number}
|
||||
</span>
|
||||
</:subtitle>
|
||||
</.svelte>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,39 +9,49 @@ defmodule ExampleWeb.LiveStaticColor do
|
|||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<h1 class="text-center text-2xl font-light my-4">
|
||||
Static Color Demo
|
||||
</h1>
|
||||
<p class="text-sm text-base-content/50 mb-8 text-center">
|
||||
Passing dynamic props to a list of Svelte components from LiveView.
|
||||
</p>
|
||||
<h1 class="text-center text-2xl font-light my-4">
|
||||
Static Color Demo
|
||||
</h1>
|
||||
<p class="text-sm text-base-content/50 mb-8 text-center">
|
||||
Passing dynamic props to a list of Svelte components from LiveView.
|
||||
</p>
|
||||
<div class="border-1 border-[var(--color-brand)] shadow-lg card p-5">
|
||||
<div class="badge badge-outline badge-sm font-medium text-base-content/70 w-fit">LiveView</div>
|
||||
<div class="flex flex-row my-4 justify-center items-center">
|
||||
<div class="flex flex-col justify-center items-center gap-4">
|
||||
<div>
|
||||
<div class="w-full border-1 border-gray-500 card card-lg p-5 flex flex-col gap-5 justify-center items-center">
|
||||
<div class="badge badge-outline badge-sm font-medium text-base-content/70 text-nowrap">LiveView Component</div>
|
||||
<div class="flex flex-col md:flex-row gap-4 items-center">
|
||||
<button class="btn bg-slate-50" phx-click="change_color_to_white">
|
||||
Change color to white
|
||||
</button>
|
||||
<button class="btn bg-red-500 text-white" phx-click="change_color_to_red">
|
||||
Change color to red
|
||||
</button>
|
||||
<button class="btn bg-[var(--color-brand)] text-white" phx-click="add_element">Add Element</button>
|
||||
</div>
|
||||
<div class="text-sm text-base-content/50 text-nowrap">Total elements: <span class="font-bold text-lg">{length(@list)}</span></div>
|
||||
<div class="badge badge-outline badge-sm font-medium text-base-content/70 text-nowrap">
|
||||
LiveView Component
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row gap-4 items-center">
|
||||
<button class="btn bg-slate-50" phx-click="change_color_to_white">
|
||||
Change color to white
|
||||
</button>
|
||||
<button class="btn bg-red-500 text-white" phx-click="change_color_to_red">
|
||||
Change color to red
|
||||
</button>
|
||||
<button class="btn bg-[var(--color-brand)] text-white" phx-click="add_element">
|
||||
Add Element
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-sm text-base-content/50 text-nowrap">
|
||||
Total elements: <span class="font-bold text-lg">{length(@list)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="my-4 text-base-content">Use LiveSvelte via a file based component (Static.svelte)</h3>
|
||||
<h3 class="my-4 text-base-content">
|
||||
Use LiveSvelte via a file based component (Static.svelte)
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
<%= for {_item, index} <- Enum.with_index(@list) do %>
|
||||
<.svelte name="Static" props={%{color: @color, index: index}} />
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<h3 class="mb-4 text-base-content">Use LiveSvelte as a function via the (~V sigil) to render the Svelte component</h3>
|
||||
<h3 class="mb-4 text-base-content">
|
||||
Use LiveSvelte as a function via the (~V sigil) to render the Svelte component
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
<%= for {_item, index} <- Enum.with_index(@list) do %>
|
||||
<.static_svelte_component color={@color} index={index} />
|
||||
|
|
|
|||
|
|
@ -11,11 +11,11 @@ defmodule ExampleWeb.LiveStruct do
|
|||
def render(assigns) do
|
||||
~H"""
|
||||
<h1 class="text-center text-2xl font-light my-4">
|
||||
Struct Demo
|
||||
</h1>
|
||||
<p class="text-sm text-base-content/50 mb-8 text-center">
|
||||
Passing a struct to Svelte.
|
||||
</p>
|
||||
Struct Demo
|
||||
</h1>
|
||||
<p class="text-sm text-base-content/50 mb-8 text-center">
|
||||
Passing a struct to Svelte.
|
||||
</p>
|
||||
|
||||
<.svelte name="Struct" props={%{struct: @struct}} socket={@socket} />
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -44,10 +44,12 @@ defmodule ExampleWeb.LiveUpload do
|
|||
</p>
|
||||
<.svelte
|
||||
name="UploadDemo"
|
||||
props={%{
|
||||
uploads: %{test_files: @uploads.test_files},
|
||||
uploaded_files: @uploaded_files
|
||||
}}
|
||||
props={
|
||||
%{
|
||||
uploads: %{test_files: @uploads.test_files},
|
||||
uploaded_files: @uploaded_files
|
||||
}
|
||||
}
|
||||
socket={@socket}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -17,9 +17,14 @@ defmodule ExampleWeb.LiveBreakingNewsTest do
|
|||
container = session |> find(Query.css("[data-testid='breaking-news-headlines']"))
|
||||
container_text = Wallaby.Element.text(container)
|
||||
found = String.contains?(container_text, text)
|
||||
|
||||
cond do
|
||||
found -> session
|
||||
attempts == 0 -> raise "timeout waiting for headline containing #{inspect(text)}"
|
||||
found ->
|
||||
session
|
||||
|
||||
attempts == 0 ->
|
||||
raise "timeout waiting for headline containing #{inspect(text)}"
|
||||
|
||||
true ->
|
||||
:timer.sleep(100)
|
||||
wait_for_headline_with_text(session, text, attempts - 1)
|
||||
|
|
@ -30,7 +35,11 @@ defmodule ExampleWeb.LiveBreakingNewsTest do
|
|||
session
|
||||
|> visit("/live-breaking-news")
|
||||
|> assert_has(Query.css("h2", text: "Breaking News"))
|
||||
|> assert_has(Query.css("p", text: "Add headlines and control the ticker speed; remove items from the list."))
|
||||
|> assert_has(
|
||||
Query.css("p",
|
||||
text: "Add headlines and control the ticker speed; remove items from the list."
|
||||
)
|
||||
)
|
||||
|> assert_has(Query.css("[data-testid='breaking-news-new-headline']"))
|
||||
|
||||
# Initial news has 5 items; at least one visible
|
||||
|
|
@ -39,7 +48,9 @@ defmodule ExampleWeb.LiveBreakingNewsTest do
|
|||
assert Enum.any?(items, fn el -> Wallaby.Element.text(el) =~ "Giant Pink Elephant" end)
|
||||
end
|
||||
|
||||
test "adding a headline via the UI shows it in the list and clears the input", %{session: session} do
|
||||
test "adding a headline via the UI shows it in the list and clears the input", %{
|
||||
session: session
|
||||
} do
|
||||
session =
|
||||
session
|
||||
|> visit("/live-breaking-news")
|
||||
|
|
@ -91,9 +102,14 @@ defmodule ExampleWeb.LiveBreakingNewsTest do
|
|||
container = session |> find(Query.css("[data-testid='breaking-news-headlines']"))
|
||||
container_text = Wallaby.Element.text(container)
|
||||
found = String.contains?(container_text, text)
|
||||
|
||||
cond do
|
||||
not found -> session
|
||||
attempts == 0 -> raise "timeout waiting for headline #{inspect(text)} to be removed"
|
||||
not found ->
|
||||
session
|
||||
|
||||
attempts == 0 ->
|
||||
raise "timeout waiting for headline #{inspect(text)} to be removed"
|
||||
|
||||
true ->
|
||||
:timer.sleep(100)
|
||||
wait_for_headline_removed(session, text, attempts - 1)
|
||||
|
|
@ -110,6 +126,7 @@ defmodule ExampleWeb.LiveBreakingNewsTest do
|
|||
_ ->
|
||||
if attempts == 0 do
|
||||
actual = Wallaby.Element.attr(ticker, "data-rate")
|
||||
|
||||
raise "timeout waiting for ticker rate (expected: #{inspect(expected)}, actual: #{inspect(actual)})"
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -15,9 +15,14 @@ defmodule ExampleWeb.LiveChatTest do
|
|||
defp wait_for_chat_bubble_with_text(session, text, attempts \\ 50) do
|
||||
bubbles = chat_bubbles(session)
|
||||
found = Enum.any?(bubbles, fn el -> Wallaby.Element.text(el) =~ text end)
|
||||
|
||||
cond do
|
||||
found -> session
|
||||
attempts == 0 -> raise "timeout waiting for chat bubble containing #{inspect(text)}"
|
||||
found ->
|
||||
session
|
||||
|
||||
attempts == 0 ->
|
||||
raise "timeout waiting for chat bubble containing #{inspect(text)}"
|
||||
|
||||
true ->
|
||||
:timer.sleep(100)
|
||||
wait_for_chat_bubble_with_text(session, text, attempts - 1)
|
||||
|
|
@ -28,12 +33,18 @@ defmodule ExampleWeb.LiveChatTest do
|
|||
session
|
||||
|> visit("/live-chat")
|
||||
|> assert_has(Query.css("h2", text: "Chat"))
|
||||
|> assert_has(Query.css("p", text: "Enter your name to join; then send messages. Your name labels your bubbles."))
|
||||
|> assert_has(
|
||||
Query.css("p",
|
||||
text: "Enter your name to join; then send messages. Your name labels your bubbles."
|
||||
)
|
||||
)
|
||||
|> assert_has(Query.css("[data-testid='chat-join-name']"))
|
||||
|> assert_has(Query.css("[data-testid='chat-join-form'] button", text: "Join"))
|
||||
end
|
||||
|
||||
test "useLiveConnection: reconnecting banner absent when connected (happy path)", %{session: session} do
|
||||
test "useLiveConnection: reconnecting banner absent when connected (happy path)", %{
|
||||
session: session
|
||||
} do
|
||||
session
|
||||
|> visit("/live-chat")
|
||||
|> fill_in(Query.css("[data-testid='chat-join-name']"), with: "Alice")
|
||||
|
|
|
|||
|
|
@ -10,8 +10,10 @@ defmodule ExampleWeb.LiveClientSideLoadingTest do
|
|||
|
||||
defp wait_for_hydration(session, count, attempts \\ 30)
|
||||
defp wait_for_hydration(_session, _count, 0), do: :ok
|
||||
|
||||
defp wait_for_hydration(session, count, attempts) do
|
||||
els = session |> all(Query.css("[data-testid='client-side-loading-content']"))
|
||||
|
||||
if length(els) >= count do
|
||||
:ok
|
||||
else
|
||||
|
|
@ -29,7 +31,12 @@ defmodule ExampleWeb.LiveClientSideLoadingTest do
|
|||
test "renders description", %{session: session} do
|
||||
session
|
||||
|> visit("/live-client-side-loading")
|
||||
|> find(Query.css("p", text: "Use the loading slot when SSR is disabled; the slot shows until the component hydrates on the client."))
|
||||
|> find(
|
||||
Query.css("p",
|
||||
text:
|
||||
"Use the loading slot when SSR is disabled; the slot shows until the component hydrates on the client."
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
test "both components hydrate and show content", %{session: session} do
|
||||
|
|
@ -39,6 +46,7 @@ defmodule ExampleWeb.LiveClientSideLoadingTest do
|
|||
wait_for_hydration(session, 2)
|
||||
|
||||
content_els = session |> all(Query.css("[data-testid='client-side-loading-content']"))
|
||||
|
||||
assert length(content_els) == 2,
|
||||
"expected 2 hydrated ClientSideLoading components, got #{length(content_els)}"
|
||||
|
||||
|
|
|
|||
|
|
@ -72,7 +72,9 @@ defmodule ExampleWeb.LiveFormTest do
|
|||
|> assert_has(Query.css("[data-testid='form-email-error']"))
|
||||
end
|
||||
|
||||
test "submitting valid form resets fields (re-submit verifies empty values)", %{session: session} do
|
||||
test "submitting valid form resets fields (re-submit verifies empty values)", %{
|
||||
session: session
|
||||
} do
|
||||
session =
|
||||
session
|
||||
|> visit("/live-form")
|
||||
|
|
|
|||
|
|
@ -16,9 +16,14 @@ defmodule ExampleWeb.LiveJsonTest do
|
|||
defp wait_for_key_count(session, expected, attempts \\ 50) do
|
||||
dd = first_key_count_dd(session)
|
||||
actual = dd && Wallaby.Element.text(dd)
|
||||
|
||||
cond do
|
||||
actual == expected -> session
|
||||
attempts == 0 -> raise "timeout waiting for key count #{inspect(expected)}, got #{inspect(actual)}"
|
||||
actual == expected ->
|
||||
session
|
||||
|
||||
attempts == 0 ->
|
||||
raise "timeout waiting for key count #{inspect(expected)}, got #{inspect(actual)}"
|
||||
|
||||
true ->
|
||||
:timer.sleep(100)
|
||||
wait_for_key_count(session, expected, attempts - 1)
|
||||
|
|
@ -29,7 +34,12 @@ defmodule ExampleWeb.LiveJsonTest do
|
|||
session
|
||||
|> visit("/live-json")
|
||||
|> assert_has(Query.css("h2", text: "Live JSON"))
|
||||
|> assert_has(Query.css("p", text: "Large payloads are patched over the wire. Compare SSR vs no-SSR and watch the WebSocket traffic when removing elements."))
|
||||
|> assert_has(
|
||||
Query.css("p",
|
||||
text:
|
||||
"Large payloads are patched over the wire. Compare SSR vs no-SSR and watch the WebSocket traffic when removing elements."
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
test "renders two sections with key length and Remove element button", %{session: session} do
|
||||
|
|
|
|||
|
|
@ -12,13 +12,19 @@ defmodule ExampleWeb.LiveLightsTest do
|
|||
if attempts == 0 do
|
||||
el = session |> find(Query.css("[data-testid='light-brightness-value']"))
|
||||
actual = Wallaby.Element.text(el)
|
||||
|
||||
raise "timeout waiting for brightness (expected: #{inspect(expected)}, actual: #{inspect(actual)})"
|
||||
end
|
||||
|
||||
el = session |> find(Query.css("[data-testid='light-brightness-value']"))
|
||||
|
||||
case Wallaby.Element.text(el) do
|
||||
^expected -> session
|
||||
_ -> :timer.sleep(100); wait_for_brightness(session, expected, attempts - 1)
|
||||
^expected ->
|
||||
session
|
||||
|
||||
_ ->
|
||||
:timer.sleep(100)
|
||||
wait_for_brightness(session, expected, attempts - 1)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -28,7 +34,9 @@ defmodule ExampleWeb.LiveLightsTest do
|
|||
|> find(Query.css("h1", text: "Light Bulb Controller"))
|
||||
end
|
||||
|
||||
test "renders LightStatusBar and LightControllers with initial brightness 10%", %{session: session} do
|
||||
test "renders LightStatusBar and LightControllers with initial brightness 10%", %{
|
||||
session: session
|
||||
} do
|
||||
session = visit(session, "/live-lights")
|
||||
|
||||
session |> find(Query.css("[data-name='LightStatusBar']"))
|
||||
|
|
|
|||
|
|
@ -15,9 +15,14 @@ defmodule ExampleWeb.LiveLogListTest do
|
|||
defp wait_for_log_item_with_text(session, text, attempts \\ 50) do
|
||||
items = log_list_items(session)
|
||||
found = Enum.any?(items, fn el -> Wallaby.Element.text(el) =~ text end)
|
||||
|
||||
cond do
|
||||
found -> session
|
||||
attempts == 0 -> raise "timeout waiting for log list item containing #{inspect(text)}"
|
||||
found ->
|
||||
session
|
||||
|
||||
attempts == 0 ->
|
||||
raise "timeout waiting for log list item containing #{inspect(text)}"
|
||||
|
||||
true ->
|
||||
:timer.sleep(100)
|
||||
wait_for_log_item_with_text(session, text, attempts - 1)
|
||||
|
|
@ -26,9 +31,14 @@ defmodule ExampleWeb.LiveLogListTest do
|
|||
|
||||
defp wait_for_at_least_one_log_item(session, attempts \\ 35) do
|
||||
items = log_list_items(session)
|
||||
|
||||
cond do
|
||||
length(items) >= 1 -> session
|
||||
attempts == 0 -> raise "timeout waiting for at least one timer-driven log entry"
|
||||
length(items) >= 1 ->
|
||||
session
|
||||
|
||||
attempts == 0 ->
|
||||
raise "timeout waiting for at least one timer-driven log entry"
|
||||
|
||||
true ->
|
||||
:timer.sleep(200)
|
||||
wait_for_at_least_one_log_item(session, attempts - 1)
|
||||
|
|
@ -39,8 +49,11 @@ defmodule ExampleWeb.LiveLogListTest do
|
|||
session
|
||||
|> visit("/live-log-list")
|
||||
|> assert_has(Query.css("h2", text: "Log stream"))
|
||||
|> assert_has(Query.css("p", text: "Add items or let the timer append entries; limit how many are shown."))
|
||||
|> assert_has(
|
||||
Query.css("p", text: "Add items or let the timer append entries; limit how many are shown.")
|
||||
)
|
||||
|> assert_has(Query.css("[data-testid='log-list-new-entry']"))
|
||||
|
||||
# Empty state may be gone if timer already appended an entry
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -13,13 +13,19 @@ defmodule ExampleWeb.LivePlusMinusHybridTest do
|
|||
if attempts == 0 do
|
||||
el = session |> find(Query.css("[data-testid='hybrid-plus-minus-value']"))
|
||||
actual = Wallaby.Element.text(el)
|
||||
|
||||
raise "timeout waiting for value (expected: #{inspect(expected)}, actual: #{inspect(actual)})"
|
||||
end
|
||||
|
||||
el = session |> find(Query.css("[data-testid='hybrid-plus-minus-value']"))
|
||||
|
||||
case Wallaby.Element.text(el) do
|
||||
^expected -> session
|
||||
_ -> :timer.sleep(100); wait_for_value(session, expected, attempts - 1)
|
||||
^expected ->
|
||||
session
|
||||
|
||||
_ ->
|
||||
:timer.sleep(100)
|
||||
wait_for_value(session, expected, attempts - 1)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -73,7 +79,9 @@ defmodule ExampleWeb.LivePlusMinusHybridTest do
|
|||
assert Wallaby.Element.text(value) == "12"
|
||||
end
|
||||
|
||||
test "multiple plus clicks with stepper 2 keep stepper at 2 and increment by 2 each time", %{session: session} do
|
||||
test "multiple plus clicks with stepper 2 keep stepper at 2 and increment by 2 each time", %{
|
||||
session: session
|
||||
} do
|
||||
session =
|
||||
session
|
||||
|> visit("/live-plus-minus-hybrid")
|
||||
|
|
|
|||
|
|
@ -13,13 +13,19 @@ defmodule ExampleWeb.LivePlusMinusTest do
|
|||
if attempts == 0 do
|
||||
el = session |> find(Query.css("[data-testid='live-plus-minus-value']"))
|
||||
actual = Wallaby.Element.text(el)
|
||||
|
||||
raise "timeout waiting for value (expected: #{inspect(expected)}, actual: #{inspect(actual)})"
|
||||
end
|
||||
|
||||
el = session |> find(Query.css("[data-testid='live-plus-minus-value']"))
|
||||
|
||||
case Wallaby.Element.text(el) do
|
||||
^expected -> session
|
||||
_ -> :timer.sleep(100); wait_for_value(session, expected, attempts - 1)
|
||||
^expected ->
|
||||
session
|
||||
|
||||
_ ->
|
||||
:timer.sleep(100)
|
||||
wait_for_value(session, expected, attempts - 1)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -25,9 +25,9 @@ defmodule ExampleWeb.LivePropsDiffTest do
|
|||
assert length(values_b) == 2
|
||||
assert length(values_c) == 2
|
||||
|
||||
for el <- values_a, do: assert Wallaby.Element.text(el) == "1"
|
||||
for el <- values_b, do: assert Wallaby.Element.text(el) == "2"
|
||||
for el <- values_c, do: assert Wallaby.Element.text(el) == "3"
|
||||
for el <- values_a, do: assert(Wallaby.Element.text(el) == "1")
|
||||
for el <- values_b, do: assert(Wallaby.Element.text(el) == "2")
|
||||
for el <- values_c, do: assert(Wallaby.Element.text(el) == "3")
|
||||
end
|
||||
|
||||
test "Increment A updates both Svelte components to A=2", %{session: session} do
|
||||
|
|
@ -38,7 +38,7 @@ defmodule ExampleWeb.LivePropsDiffTest do
|
|||
|
||||
values_a = session |> all(Query.css("[data-testid='props-diff-value-a']"))
|
||||
assert length(values_a) == 2
|
||||
for el <- values_a, do: assert Wallaby.Element.text(el) == "2"
|
||||
for el <- values_a, do: assert(Wallaby.Element.text(el) == "2")
|
||||
end
|
||||
|
||||
test "Increment B then C updates displayed values", %{session: session} do
|
||||
|
|
@ -52,8 +52,8 @@ defmodule ExampleWeb.LivePropsDiffTest do
|
|||
values_c = session |> all(Query.css("[data-testid='props-diff-value-c']"))
|
||||
assert length(values_b) == 2
|
||||
assert length(values_c) == 2
|
||||
for el <- values_b, do: assert Wallaby.Element.text(el) == "3"
|
||||
for el <- values_c, do: assert Wallaby.Element.text(el) == "4"
|
||||
for el <- values_b, do: assert(Wallaby.Element.text(el) == "3")
|
||||
for el <- values_c, do: assert(Wallaby.Element.text(el) == "4")
|
||||
end
|
||||
|
||||
test "multiple increments leave all displayed values in sync", %{session: session} do
|
||||
|
|
@ -66,7 +66,7 @@ defmodule ExampleWeb.LivePropsDiffTest do
|
|||
|
||||
values_a = session |> all(Query.css("[data-testid='props-diff-value-a']"))
|
||||
values_b = session |> all(Query.css("[data-testid='props-diff-value-b']"))
|
||||
for el <- values_a, do: assert Wallaby.Element.text(el) == "3"
|
||||
for el <- values_b, do: assert Wallaby.Element.text(el) == "3"
|
||||
for el <- values_a, do: assert(Wallaby.Element.text(el) == "3")
|
||||
for el <- values_b, do: assert(Wallaby.Element.text(el) == "3")
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -12,13 +12,19 @@ defmodule ExampleWeb.LiveSigilTest do
|
|||
if attempts == 0 do
|
||||
el = session |> find(Query.css("[data-testid='#{testid}']"))
|
||||
actual = Wallaby.Element.text(el)
|
||||
|
||||
raise "timeout waiting for #{testid} (expected: #{inspect(expected)}, actual: #{inspect(actual)})"
|
||||
end
|
||||
|
||||
el = session |> find(Query.css("[data-testid='#{testid}']"))
|
||||
|
||||
case Wallaby.Element.text(el) do
|
||||
^expected -> session
|
||||
_ -> :timer.sleep(100); wait_for_sigil_value(session, testid, expected, attempts - 1)
|
||||
^expected ->
|
||||
session
|
||||
|
||||
_ ->
|
||||
:timer.sleep(100)
|
||||
wait_for_sigil_value(session, testid, expected, attempts - 1)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ defmodule ExampleWeb.LiveSimpleCounterTest do
|
|||
"""
|
||||
@moduletag :e2e
|
||||
|
||||
defp native_increment_js, do: "document.querySelector(\"[data-testid='live-simple-counter-increment']\").click()"
|
||||
defp native_increment_js,
|
||||
do: "document.querySelector(\"[data-testid='live-simple-counter-increment']\").click()"
|
||||
|
||||
defp click_native_increment(session) do
|
||||
session |> Wallaby.Browser.execute_script(native_increment_js())
|
||||
|
|
@ -20,17 +21,22 @@ defmodule ExampleWeb.LiveSimpleCounterTest do
|
|||
assert length(client_values) == 2, "expected 2 SimpleCounter client value elements"
|
||||
actual_first = Wallaby.Element.text(Enum.at(client_values, 0))
|
||||
actual_second = Wallaby.Element.text(Enum.at(client_values, 1))
|
||||
|
||||
assert actual_first == expected_first,
|
||||
"first component client state was affected by server increment (expected: #{expected_first}, got: #{actual_first})"
|
||||
|
||||
assert actual_second == expected_second,
|
||||
"second component client state was affected by server increment (expected: #{expected_second}, got: #{actual_second})"
|
||||
|
||||
session
|
||||
end
|
||||
|
||||
defp wait_for_client_counters(session, count, attempts \\ 30)
|
||||
defp wait_for_client_counters(_session, _count, 0), do: :ok
|
||||
|
||||
defp wait_for_client_counters(session, count, attempts) do
|
||||
els = session |> all(Query.css("[data-testid='simple-counter-client-value']"))
|
||||
|
||||
if length(els) >= count do
|
||||
:ok
|
||||
else
|
||||
|
|
@ -47,14 +53,20 @@ defmodule ExampleWeb.LiveSimpleCounterTest do
|
|||
defp wait_for_counter_value(session, expected, 0) do
|
||||
el = session |> find(Query.css("[data-testid='live-simple-counter-value']"))
|
||||
actual = Wallaby.Element.text(el)
|
||||
|
||||
raise "timeout waiting for counter value (expected: #{inspect(expected)}, actual: #{inspect(actual)})"
|
||||
end
|
||||
|
||||
defp wait_for_counter_value(session, expected, attempts) do
|
||||
el = session |> find(Query.css("[data-testid='live-simple-counter-value']"))
|
||||
|
||||
case Wallaby.Element.text(el) do
|
||||
^expected -> session
|
||||
_ -> :timer.sleep(100); wait_for_counter_value(session, expected, attempts - 1)
|
||||
^expected ->
|
||||
session
|
||||
|
||||
_ ->
|
||||
:timer.sleep(100)
|
||||
wait_for_counter_value(session, expected, attempts - 1)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -91,9 +103,11 @@ defmodule ExampleWeb.LiveSimpleCounterTest do
|
|||
|> click(Query.css("[data-testid='live-simple-counter-increment']"))
|
||||
|
||||
# Each SimpleCounter has a Server card with span.text-brand for the server number
|
||||
svelte_server_numbers = session |> all(Query.css("[data-name='SimpleCounter'] span.text-brand"))
|
||||
svelte_server_numbers =
|
||||
session |> all(Query.css("[data-name='SimpleCounter'] span.text-brand"))
|
||||
|
||||
assert length(svelte_server_numbers) >= 2
|
||||
for el <- Enum.take(svelte_server_numbers, 2), do: assert Wallaby.Element.text(el) == "11"
|
||||
for el <- Enum.take(svelte_server_numbers, 2), do: assert(Wallaby.Element.text(el) == "11")
|
||||
end
|
||||
|
||||
test "renders client counter at 1 in each SimpleCounter", %{session: session} do
|
||||
|
|
@ -102,7 +116,7 @@ defmodule ExampleWeb.LiveSimpleCounterTest do
|
|||
wait_for_client_counters(session, 2)
|
||||
client_values = session |> all(Query.css("[data-testid='simple-counter-client-value']"))
|
||||
assert length(client_values) == 2
|
||||
for el <- client_values, do: assert Wallaby.Element.text(el) == "1"
|
||||
for el <- client_values, do: assert(Wallaby.Element.text(el) == "1")
|
||||
end
|
||||
|
||||
test "clicking client +1 updates only that component's client counter", %{session: session} do
|
||||
|
|
@ -111,7 +125,9 @@ defmodule ExampleWeb.LiveSimpleCounterTest do
|
|||
session |> find(Query.css("[data-testid='live-simple-counter-value']"))
|
||||
|
||||
# Click the first SimpleCounter's client +1 button
|
||||
[first_client_btn | _] = session |> all(Query.css("[data-testid='simple-counter-client-increment']"))
|
||||
[first_client_btn | _] =
|
||||
session |> all(Query.css("[data-testid='simple-counter-client-increment']"))
|
||||
|
||||
Wallaby.Element.click(first_client_btn)
|
||||
|
||||
client_values = session |> all(Query.css("[data-testid='simple-counter-client-value']"))
|
||||
|
|
@ -126,7 +142,9 @@ defmodule ExampleWeb.LiveSimpleCounterTest do
|
|||
session |> find(Query.css("[data-testid='live-simple-counter-value']", text: "10"))
|
||||
|
||||
# Bump only the first component's client counter: 1 → 2
|
||||
[first_client_btn | _] = session |> all(Query.css("[data-testid='simple-counter-client-increment']"))
|
||||
[first_client_btn | _] =
|
||||
session |> all(Query.css("[data-testid='simple-counter-client-increment']"))
|
||||
|
||||
Wallaby.Element.click(first_client_btn)
|
||||
:timer.sleep(200)
|
||||
assert_client_values_unchanged(session, "2", "1")
|
||||
|
|
@ -141,7 +159,9 @@ defmodule ExampleWeb.LiveSimpleCounterTest do
|
|||
session |> find(Query.css("[data-testid='live-simple-counter-value']", text: "10"))
|
||||
|
||||
# First component client: 1 → 2 → 3
|
||||
[first_client_btn | _] = session |> all(Query.css("[data-testid='simple-counter-client-increment']"))
|
||||
[first_client_btn | _] =
|
||||
session |> all(Query.css("[data-testid='simple-counter-client-increment']"))
|
||||
|
||||
Wallaby.Element.click(first_client_btn)
|
||||
:timer.sleep(100)
|
||||
Wallaby.Element.click(first_client_btn)
|
||||
|
|
@ -161,7 +181,9 @@ defmodule ExampleWeb.LiveSimpleCounterTest do
|
|||
assert_client_values_unchanged(session, "3", "1")
|
||||
end
|
||||
|
||||
test "client state on second component only is unchanged by server increments", %{session: session} do
|
||||
test "client state on second component only is unchanged by server increments", %{
|
||||
session: session
|
||||
} do
|
||||
session = visit(session, "/live-simple-counter")
|
||||
wait_for_client_counters(session, 2)
|
||||
session |> find(Query.css("[data-testid='live-simple-counter-value']", text: "10"))
|
||||
|
|
@ -180,7 +202,9 @@ defmodule ExampleWeb.LiveSimpleCounterTest do
|
|||
assert_client_values_unchanged(session, "1", "2")
|
||||
end
|
||||
|
||||
test "server increment never resets or changes client state on both components", %{session: session} do
|
||||
test "server increment never resets or changes client state on both components", %{
|
||||
session: session
|
||||
} do
|
||||
session = visit(session, "/live-simple-counter")
|
||||
wait_for_client_counters(session, 2)
|
||||
session |> find(Query.css("[data-testid='live-simple-counter-value']", text: "10"))
|
||||
|
|
@ -192,12 +216,14 @@ defmodule ExampleWeb.LiveSimpleCounterTest do
|
|||
Wallaby.Element.click(Enum.at(client_btns, 0))
|
||||
:timer.sleep(100)
|
||||
end
|
||||
|
||||
:timer.sleep(100)
|
||||
# Second component: 1 → 2 → 3 → 4 → 5
|
||||
for _ <- 1..4 do
|
||||
Wallaby.Element.click(Enum.at(client_btns, 1))
|
||||
:timer.sleep(80)
|
||||
end
|
||||
|
||||
:timer.sleep(200)
|
||||
assert_client_values_unchanged(session, "4", "5")
|
||||
|
||||
|
|
@ -210,7 +236,8 @@ defmodule ExampleWeb.LiveSimpleCounterTest do
|
|||
assert_client_values_unchanged(session, "4", "5")
|
||||
end
|
||||
|
||||
test "server increments first, then client state: client state still unaffected by later server increments", %{session: session} do
|
||||
test "server increments first, then client state: client state still unaffected by later server increments",
|
||||
%{session: session} do
|
||||
session = visit(session, "/live-simple-counter")
|
||||
wait_for_client_counters(session, 2)
|
||||
session |> find(Query.css("[data-testid='live-simple-counter-value']", text: "10"))
|
||||
|
|
@ -223,7 +250,14 @@ defmodule ExampleWeb.LiveSimpleCounterTest do
|
|||
client_btns = session |> all(Query.css("[data-testid='simple-counter-client-increment']"))
|
||||
Wallaby.Element.click(Enum.at(client_btns, 0))
|
||||
:timer.sleep(100)
|
||||
for _ <- 1..3, do: (Wallaby.Element.click(Enum.at(client_btns, 1)); :timer.sleep(80))
|
||||
|
||||
for _ <- 1..3,
|
||||
do:
|
||||
(
|
||||
Wallaby.Element.click(Enum.at(client_btns, 1))
|
||||
:timer.sleep(80)
|
||||
)
|
||||
|
||||
:timer.sleep(200)
|
||||
assert_client_values_unchanged(session, "2", "4")
|
||||
|
||||
|
|
|
|||
|
|
@ -8,16 +8,28 @@ defmodule ExampleWeb.LiveSlotsDynamicTest do
|
|||
|
||||
@moduletag :e2e
|
||||
|
||||
test "page mounts and shows heading, description, slots, and initial number", %{session: session} do
|
||||
test "page mounts and shows heading, description, slots, and initial number", %{
|
||||
session: session
|
||||
} do
|
||||
session
|
||||
|> visit("/live-slots-dynamic")
|
||||
|> assert_has(Query.css("h2", text: "Dynamic slots"))
|
||||
|> assert_has(Query.css("p", text: "Default slot and named slot (:subtitle) both receive LiveView state; the button updates the number."))
|
||||
|> assert_has(
|
||||
Query.css("p",
|
||||
text:
|
||||
"Default slot and named slot (:subtitle) both receive LiveView state; the button updates the number."
|
||||
)
|
||||
)
|
||||
|
||||
# Slots card: badge, button, Opening/Closing, subtitle
|
||||
session |> assert_has(Query.css("[data-testid='slots-card']"))
|
||||
session |> assert_has(Query.css("[data-testid='slots-badge']", text: "Slots"))
|
||||
session |> assert_has(Query.css("[data-testid='slots-dynamic-increment']", text: "Increment the number"))
|
||||
|
||||
session
|
||||
|> assert_has(
|
||||
Query.css("[data-testid='slots-dynamic-increment']", text: "Increment the number")
|
||||
)
|
||||
|
||||
session |> assert_has(Query.css("[data-testid='slots-opening']", text: "Opening"))
|
||||
session |> assert_has(Query.css("[data-testid='slots-closing']", text: "Closing"))
|
||||
session |> assert_has(Query.css("[data-testid='slots-subtitle']", text: "Svelte subtitle"))
|
||||
|
|
@ -45,12 +57,18 @@ defmodule ExampleWeb.LiveSlotsDynamicTest do
|
|||
end
|
||||
|
||||
defp wait_for_number(session, expected, attempts \\ 30) do
|
||||
default_span = session |> all(Query.css("[data-testid='slots-dynamic-number']")) |> List.first()
|
||||
default_span =
|
||||
session |> all(Query.css("[data-testid='slots-dynamic-number']")) |> List.first()
|
||||
|
||||
actual = default_span && Wallaby.Element.text(default_span)
|
||||
|
||||
cond do
|
||||
actual == expected -> session
|
||||
actual == expected ->
|
||||
session
|
||||
|
||||
attempts == 0 ->
|
||||
raise "timeout waiting for number #{inspect(expected)}, got #{inspect(actual)}"
|
||||
|
||||
true ->
|
||||
:timer.sleep(100)
|
||||
wait_for_number(session, expected, attempts - 1)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,11 @@ defmodule ExampleWeb.LiveSlotsSimpleTest do
|
|||
session
|
||||
|> visit("/live-slots-simple")
|
||||
|> assert_has(Query.css("h2", text: "Simple slots"))
|
||||
|> assert_has(Query.css("p", text: "Phoenix slots are passed into the Svelte component as the default slot content."))
|
||||
|> assert_has(
|
||||
Query.css("p",
|
||||
text: "Phoenix slots are passed into the Svelte component as the default slot content."
|
||||
)
|
||||
)
|
||||
|
||||
# Slots card: badge and slot content
|
||||
session |> assert_has(Query.css("[data-testid='slots-card']"))
|
||||
|
|
|
|||
|
|
@ -13,7 +13,11 @@ defmodule ExampleWeb.LiveStaticColorTest do
|
|||
session
|
||||
|> visit("/live-static-color")
|
||||
|> assert_has(Query.css("h1", text: "Static Color Demo"))
|
||||
|> assert_has(Query.css("p", text: "Passing dynamic props to a list of Svelte components from LiveView."))
|
||||
|> assert_has(
|
||||
Query.css("p",
|
||||
text: "Passing dynamic props to a list of Svelte components from LiveView."
|
||||
)
|
||||
)
|
||||
|
||||
# There are two Svelte grids (file-based + ~V sigil), each rendering the same color span.
|
||||
session = wait_for_svelte_count(session, 6)
|
||||
|
|
@ -25,7 +29,9 @@ defmodule ExampleWeb.LiveStaticColorTest do
|
|||
end
|
||||
end
|
||||
|
||||
test "adding an element increases Svelte component count and preserves all existing ones", %{session: session} do
|
||||
test "adding an element increases Svelte component count and preserves all existing ones", %{
|
||||
session: session
|
||||
} do
|
||||
session = visit(session, "/live-static-color")
|
||||
session = wait_for_svelte_count(session, 6)
|
||||
|
||||
|
|
@ -50,7 +56,7 @@ defmodule ExampleWeb.LiveStaticColorTest do
|
|||
session = wait_for_svelte_count(session, 6)
|
||||
svelte_values = session |> all(Query.css("[data-testid='static-color-svelte-value']"))
|
||||
assert length(svelte_values) == 6
|
||||
for value <- svelte_values, do: assert Wallaby.Element.text(value) == "RED"
|
||||
for value <- svelte_values, do: assert(Wallaby.Element.text(value) == "RED")
|
||||
end
|
||||
|
||||
test "adding elements after color change preserves color in all components", %{session: session} do
|
||||
|
|
@ -64,14 +70,19 @@ defmodule ExampleWeb.LiveStaticColorTest do
|
|||
session = wait_for_svelte_count(session, 8)
|
||||
svelte_values = session |> all(Query.css("[data-testid='static-color-svelte-value']"))
|
||||
assert length(svelte_values) == 8
|
||||
for value <- svelte_values, do: assert Wallaby.Element.text(value) == "RED"
|
||||
for value <- svelte_values, do: assert(Wallaby.Element.text(value) == "RED")
|
||||
end
|
||||
|
||||
defp wait_for_svelte_count(session, expected, attempts \\ 80) do
|
||||
count = session |> all(Query.css("[data-testid='static-color-svelte-value']")) |> length()
|
||||
|
||||
cond do
|
||||
count >= expected -> session
|
||||
attempts == 0 -> raise "timeout waiting for #{expected} Svelte value elements, got #{count}"
|
||||
count >= expected ->
|
||||
session
|
||||
|
||||
attempts == 0 ->
|
||||
raise "timeout waiting for #{expected} Svelte value elements, got #{count}"
|
||||
|
||||
true ->
|
||||
:timer.sleep(100)
|
||||
wait_for_svelte_count(session, expected, attempts - 1)
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@ defmodule ExampleWeb.LiveStructTest do
|
|||
session |> find(Query.css("p", text: "Passing a struct to Svelte."))
|
||||
end
|
||||
|
||||
test "struct data flows from LiveView to Svelte component and updates on server events", %{session: session} do
|
||||
test "struct data flows from LiveView to Svelte component and updates on server events", %{
|
||||
session: session
|
||||
} do
|
||||
session = visit(session, "/live-struct")
|
||||
|
||||
# Verify initial struct from LiveView (%User{name: "Bob", age: 42})
|
||||
|
|
|
|||
|
|
@ -46,7 +46,9 @@ defmodule ExampleWeb.LiveUploadTest do
|
|||
|
||||
# Create a temp file for upload tests, cleaned up after the test.
|
||||
defp tmp_upload_file(content \\ "test upload content") do
|
||||
path = Path.join(System.tmp_dir!(), "live_upload_test_#{System.unique_integer([:positive])}.txt")
|
||||
path =
|
||||
Path.join(System.tmp_dir!(), "live_upload_test_#{System.unique_integer([:positive])}.txt")
|
||||
|
||||
File.write!(path, content)
|
||||
path
|
||||
end
|
||||
|
|
|
|||
|
|
@ -25,7 +25,9 @@ defmodule ExampleWeb.PhoenixTest.LiveBreakingNewsTest do
|
|||
conn
|
||||
|> visit("/live-breaking-news")
|
||||
|> assert_has("h2", text: "Breaking News")
|
||||
|> assert_has("p", text: "Add headlines and control the ticker speed; remove items from the list.")
|
||||
|> assert_has("p",
|
||||
text: "Add headlines and control the ticker speed; remove items from the list."
|
||||
)
|
||||
end
|
||||
|
||||
test "renders inline Svelte mount and initial news in props", %{conn: conn} do
|
||||
|
|
|
|||
|
|
@ -25,7 +25,9 @@ defmodule ExampleWeb.PhoenixTest.LiveChatTest do
|
|||
conn
|
||||
|> visit("/live-chat")
|
||||
|> assert_has("h2", text: "Chat")
|
||||
|> assert_has("p", text: "Enter your name to join; then send messages. Your name labels your bubbles.")
|
||||
|> assert_has("p",
|
||||
text: "Enter your name to join; then send messages. Your name labels your bubbles."
|
||||
)
|
||||
|> assert_has("[data-testid='chat-join-name']")
|
||||
|> assert_has("[data-testid='chat-join-form'] button", text: "Join")
|
||||
end
|
||||
|
|
|
|||
|
|
@ -14,7 +14,10 @@ defmodule ExampleWeb.PhoenixTest.LiveClientSideLoadingTest do
|
|||
conn
|
||||
|> visit("/live-client-side-loading")
|
||||
|> assert_has("[data-testid='client-side-loading-heading']", text: "Client-side loading")
|
||||
|> assert_has("p", text: "Use the loading slot when SSR is disabled; the slot shows until the component hydrates on the client.")
|
||||
|> assert_has("p",
|
||||
text:
|
||||
"Use the loading slot when SSR is disabled; the slot shows until the component hydrates on the client."
|
||||
)
|
||||
end
|
||||
|
||||
test "renders two ClientSideLoading mount points", %{conn: conn} do
|
||||
|
|
|
|||
|
|
@ -25,7 +25,10 @@ defmodule ExampleWeb.PhoenixTest.LiveJsonTest do
|
|||
conn
|
||||
|> visit("/live-json")
|
||||
|> assert_has("h2", text: "Live JSON")
|
||||
|> assert_has("p", text: "Large payloads are patched over the wire. Compare SSR vs no-SSR and watch the WebSocket traffic when removing elements.")
|
||||
|> assert_has("p",
|
||||
text:
|
||||
"Large payloads are patched over the wire. Compare SSR vs no-SSR and watch the WebSocket traffic when removing elements."
|
||||
)
|
||||
end
|
||||
|
||||
test "renders two sections (SSR and No SSR) with LiveJson component", %{conn: conn} do
|
||||
|
|
|
|||
|
|
@ -36,10 +36,14 @@ defmodule ExampleWeb.PhoenixTest.LiveLightsTest do
|
|||
conn
|
||||
|> visit("/live-lights")
|
||||
|> assert_has("h1", text: "Light Bulb Controller")
|
||||
|> assert_has("p", text: "Same LiveView state drives the native counter and both Svelte components.")
|
||||
|> assert_has("p",
|
||||
text: "Same LiveView state drives the native counter and both Svelte components."
|
||||
)
|
||||
end
|
||||
|
||||
test "renders LightStatusBar and LightControllers Svelte components with initial state", %{conn: conn} do
|
||||
test "renders LightStatusBar and LightControllers Svelte components with initial state", %{
|
||||
conn: conn
|
||||
} do
|
||||
conn
|
||||
|> visit("/live-lights")
|
||||
|> assert_has("[data-name='LightStatusBar']", count: 1)
|
||||
|
|
|
|||
|
|
@ -25,7 +25,9 @@ defmodule ExampleWeb.PhoenixTest.LiveLogListTest do
|
|||
conn
|
||||
|> visit("/live-log-list")
|
||||
|> assert_has("h2", text: "Log stream")
|
||||
|> assert_has("p", text: "Add items or let the timer append entries; limit how many are shown.")
|
||||
|> assert_has("p",
|
||||
text: "Add items or let the timer append entries; limit how many are shown."
|
||||
)
|
||||
end
|
||||
|
||||
test "renders LogList mount and initial empty items in props", %{conn: conn} do
|
||||
|
|
|
|||
|
|
@ -25,7 +25,10 @@ defmodule ExampleWeb.PhoenixTest.LiveNotesOtpTest do
|
|||
conn
|
||||
|> visit("/live-notes-otp")
|
||||
|> assert_has("[data-testid='notes-otp-heading']", text: "Notes (OTP)")
|
||||
|> assert_has("p", text: "Ecto structs are encoded automatically. Changes sync in real time across all browsers via PubSub.")
|
||||
|> assert_has("p",
|
||||
text:
|
||||
"Ecto structs are encoded automatically. Changes sync in real time across all browsers via PubSub."
|
||||
)
|
||||
end
|
||||
|
||||
test "renders NotesApp with form and empty state or notes in props", %{conn: conn} do
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ defmodule ExampleWeb.PhoenixTest.LivePlusMinusHybridTest do
|
|||
:ok
|
||||
end
|
||||
|
||||
test "renders page heading and description", %{conn: conn} do
|
||||
test "renders page heading and description", %{conn: conn} do
|
||||
conn
|
||||
|> visit("/live-plus-minus-hybrid")
|
||||
|> assert_has("h2", text: "Plus / Minus (Hybrid)")
|
||||
|
|
|
|||
|
|
@ -70,7 +70,9 @@ defmodule ExampleWeb.PhoenixTest.LivePropsDiffTest do
|
|||
|> assert_has("[data-use-diff='false'][data-props*='\"b\":2']")
|
||||
end
|
||||
|
||||
test "after update diff-off component has full props, diff-on has only changed keys", %{conn: conn} do
|
||||
test "after update diff-off component has full props, diff-on has only changed keys", %{
|
||||
conn: conn
|
||||
} do
|
||||
conn
|
||||
|> visit("/live-props-diff")
|
||||
|> click_button("Increment A")
|
||||
|
|
|
|||
|
|
@ -26,7 +26,9 @@ defmodule ExampleWeb.PhoenixTest.LiveSigilTest do
|
|||
conn
|
||||
|> visit("/live-sigil")
|
||||
|> assert_has("h1", text: "Svelte template (~V sigil)")
|
||||
|> assert_has("p", text: "Inline Svelte in LiveView: server state and client state in one template.")
|
||||
|> assert_has("p",
|
||||
text: "Inline Svelte in LiveView: server state and client state in one template."
|
||||
)
|
||||
end
|
||||
|
||||
test "renders initial server, client, and combined values", %{conn: conn} do
|
||||
|
|
|
|||
|
|
@ -34,14 +34,19 @@ defmodule ExampleWeb.PhoenixTest.LiveSimpleCounterTest do
|
|||
|
||||
defp assert_client_counter_rendered_from_state(conn) do
|
||||
conn
|
||||
|> assert_has("[data-testid='simple-counter-client-value']", text: "#{@initial_client_value}", count: 2)
|
||||
|> assert_has("[data-testid='simple-counter-client-value']",
|
||||
text: "#{@initial_client_value}",
|
||||
count: 2
|
||||
)
|
||||
end
|
||||
|
||||
test "renders page heading and description", %{conn: conn} do
|
||||
conn
|
||||
|> visit("/live-simple-counter")
|
||||
|> assert_has("h1", text: "Simple Counter Demo")
|
||||
|> assert_has("p", text: "Same LiveView state drives the native counter and both Svelte components.")
|
||||
|> assert_has("p",
|
||||
text: "Same LiveView state drives the native counter and both Svelte components."
|
||||
)
|
||||
end
|
||||
|
||||
test "renders initial counter and two SimpleCounter Svelte components", %{conn: conn} do
|
||||
|
|
@ -108,7 +113,9 @@ defmodule ExampleWeb.PhoenixTest.LiveSimpleCounterTest do
|
|||
|> assert_client_counter_rendered_from_state()
|
||||
end
|
||||
|
||||
test "props diffing: after increment, data-use-diff is true and props reflect new value", %{conn: conn} do
|
||||
test "props diffing: after increment, data-use-diff is true and props reflect new value", %{
|
||||
conn: conn
|
||||
} do
|
||||
conn
|
||||
|> visit("/live-simple-counter")
|
||||
|> assert_has("[data-testid='live-simple-counter-value']", text: "10")
|
||||
|
|
|
|||
|
|
@ -24,7 +24,10 @@ defmodule ExampleWeb.PhoenixTest.LiveSlotsDynamicTest do
|
|||
conn
|
||||
|> visit("/live-slots-dynamic")
|
||||
|> assert_has("h2", text: "Dynamic slots")
|
||||
|> assert_has("p", text: "Default slot and named slot (:subtitle) both receive LiveView state; the button updates the number.")
|
||||
|> assert_has("p",
|
||||
text:
|
||||
"Default slot and named slot (:subtitle) both receive LiveView state; the button updates the number."
|
||||
)
|
||||
end
|
||||
|
||||
test "renders Slots with default and subtitle slots showing initial number", %{conn: conn} do
|
||||
|
|
|
|||
|
|
@ -23,7 +23,9 @@ defmodule ExampleWeb.PhoenixTest.LiveSlotsSimpleTest do
|
|||
conn
|
||||
|> visit("/live-slots-simple")
|
||||
|> assert_has("h2", text: "Simple slots")
|
||||
|> assert_has("p", text: "Phoenix slots are passed into the Svelte component as the default slot content.")
|
||||
|> assert_has("p",
|
||||
text: "Phoenix slots are passed into the Svelte component as the default slot content."
|
||||
)
|
||||
end
|
||||
|
||||
test "renders Slots component with default slot content", %{conn: conn} do
|
||||
|
|
|
|||
|
|
@ -8,10 +8,14 @@ defmodule ExampleWeb.PhoenixTest.LiveStaticColorTest do
|
|||
conn
|
||||
|> visit("/live-static-color")
|
||||
|> assert_has("h1", text: "Static Color Demo")
|
||||
|> assert_has("p", text: "Passing dynamic props to a list of Svelte components from LiveView.")
|
||||
|> assert_has("p",
|
||||
text: "Passing dynamic props to a list of Svelte components from LiveView."
|
||||
)
|
||||
end
|
||||
|
||||
test "renders both Svelte mountpoints (file-based + ~V sigil) initially with white color", %{conn: conn} do
|
||||
test "renders both Svelte mountpoints (file-based + ~V sigil) initially with white color", %{
|
||||
conn: conn
|
||||
} do
|
||||
conn
|
||||
|> visit("/live-static-color")
|
||||
|> assert_has("[data-name='Static']", count: 3)
|
||||
|
|
@ -19,7 +23,9 @@ defmodule ExampleWeb.PhoenixTest.LiveStaticColorTest do
|
|||
|> assert_has("[data-props*='\"color\":\"white\"']", count: 6)
|
||||
end
|
||||
|
||||
test "each Svelte component receives its index in props (twice: file-based + ~V sigil)", %{conn: conn} do
|
||||
test "each Svelte component receives its index in props (twice: file-based + ~V sigil)", %{
|
||||
conn: conn
|
||||
} do
|
||||
conn
|
||||
|> visit("/live-static-color")
|
||||
|> assert_has("[data-props*='\"index\":0']", count: 2)
|
||||
|
|
|
|||
|
|
@ -31,7 +31,9 @@ defmodule ExampleWeb.PhoenixTest.PlusMinusSvelteTest do
|
|||
|> assert_has("[data-props*='\"number\":10']")
|
||||
end
|
||||
|
||||
test "renders initial value and plus/minus buttons in HTML by data-testid with SSR", %{conn: conn} do
|
||||
test "renders initial value and plus/minus buttons in HTML by data-testid with SSR", %{
|
||||
conn: conn
|
||||
} do
|
||||
conn
|
||||
|> visit("/plus-minus-svelte")
|
||||
|> assert_has("[data-testid='plus-minus-value']", text: "10")
|
||||
|
|
|
|||
|
|
@ -11,13 +11,19 @@ defmodule ExampleWeb.PlusMinusSvelteTest do
|
|||
if attempts == 0 do
|
||||
el = session |> find(Query.css("[data-testid='plus-minus-value']"))
|
||||
actual = Wallaby.Element.text(el)
|
||||
|
||||
raise "timeout waiting for value (expected: #{inspect(expected)}, actual: #{inspect(actual)})"
|
||||
end
|
||||
|
||||
el = session |> find(Query.css("[data-testid='plus-minus-value']"))
|
||||
|
||||
case Wallaby.Element.text(el) do
|
||||
^expected -> session
|
||||
_ -> :timer.sleep(100); wait_for_value(session, expected, attempts - 1)
|
||||
^expected ->
|
||||
session
|
||||
|
||||
_ ->
|
||||
:timer.sleep(100)
|
||||
wait_for_value(session, expected, attempts - 1)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
47
lib/reload.ex
Normal file
47
lib/reload.ex
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
defmodule LiveSvelte.Reload do
|
||||
@moduledoc """
|
||||
Utilities for easier integration with Vite in development.
|
||||
"""
|
||||
use Phoenix.Component
|
||||
|
||||
attr :assets, :list, required: true
|
||||
slot :inner_block, required: true, doc: "rendered when Vite path is not defined (production)"
|
||||
|
||||
@doc """
|
||||
Renders Vite dev server assets in development, falls back to compiled assets in production.
|
||||
|
||||
When `config :live_svelte, vite_host: "http://localhost:5173"` is set, injects:
|
||||
- `@vite/client` script (enables Vite HMR WebSocket)
|
||||
- CSS assets as `<link rel="stylesheet">` tags
|
||||
- JS assets as `<script type="module">` tags
|
||||
|
||||
When `vite_host` is not configured, renders the `inner_block` unchanged.
|
||||
|
||||
## Example
|
||||
|
||||
<LiveSvelte.Reload.vite_assets assets={["/js/app.js"]}>
|
||||
<link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
|
||||
<script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
|
||||
</script>
|
||||
</LiveSvelte.Reload.vite_assets>
|
||||
"""
|
||||
def vite_assets(assigns) do
|
||||
vite_host = Application.get_env(:live_svelte, :vite_host)
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:vite_host, vite_host)
|
||||
|> assign(:stylesheets, for(path <- assigns.assets, String.ends_with?(path, ".css"), do: path))
|
||||
|> assign(:javascripts, for(path <- assigns.assets, String.ends_with?(path, ".js"), do: path))
|
||||
|
||||
~H"""
|
||||
<%= if @vite_host do %>
|
||||
<script type="module" src={Path.join(@vite_host, "@vite/client")}></script>
|
||||
<link :for={path <- @stylesheets} rel="stylesheet" href={Path.join(@vite_host, path)} />
|
||||
<script :for={path <- @javascripts} type="module" src={Path.join(@vite_host, path)}></script>
|
||||
<% else %>
|
||||
<%= render_slot(@inner_block) %>
|
||||
<% end %>
|
||||
"""
|
||||
end
|
||||
end
|
||||
98
lib/ssr/vite_js.ex
Normal file
98
lib/ssr/vite_js.ex
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
defmodule LiveSvelte.SSR.ViteJS do
|
||||
@moduledoc """
|
||||
Implements SSR by making a POST request to `http://{:vite_host}/ssr_render`.
|
||||
|
||||
`ssr_render` is implemented as a Vite plugin. Add it to the `vite.config.js` plugins section:
|
||||
|
||||
```javascript
|
||||
import liveSveltePlugin from "live_svelte/vitePlugin"
|
||||
|
||||
export default {
|
||||
plugins: [liveSveltePlugin()],
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
In `config/dev.exs`:
|
||||
|
||||
```elixir
|
||||
config :live_svelte, ssr_module: LiveSvelte.SSR.ViteJS
|
||||
config :live_svelte, vite_host: "http://localhost:5173"
|
||||
```
|
||||
"""
|
||||
@behaviour LiveSvelte.SSR
|
||||
|
||||
def render(name, props, slots) do
|
||||
data = Jason.encode!(%{name: name, props: props, slots: slots})
|
||||
url = vite_path("/ssr_render")
|
||||
params = {String.to_charlist(url), [], ~c"application/json", data}
|
||||
|
||||
case :httpc.request(:post, params, [], []) do
|
||||
{:ok, {{_, 200, _}, _headers, body}} ->
|
||||
Jason.decode!(:erlang.list_to_binary(body))
|
||||
|
||||
{:ok, {{_, 500, _}, _headers, body}} ->
|
||||
body_binary = :erlang.list_to_binary(body)
|
||||
|
||||
message =
|
||||
case Jason.decode(body_binary) do
|
||||
{:ok, %{"error" => %{"message" => msg, "loc" => loc, "frame" => frame}}} ->
|
||||
"#{msg}\n#{loc["file"]}:#{loc["line"]}:#{loc["column"]}\n#{frame}"
|
||||
|
||||
{:ok, %{"error" => %{"stack" => stack}}} ->
|
||||
stack
|
||||
|
||||
_ ->
|
||||
"Unexpected Vite SSR response: 500 #{body_binary}"
|
||||
end
|
||||
|
||||
raise %LiveSvelte.SSR.NotConfigured{message: message}
|
||||
|
||||
{:ok, {{_, status, code}, _headers, _body}} ->
|
||||
raise %LiveSvelte.SSR.NotConfigured{
|
||||
message: "Unexpected Vite SSR response: #{status} #{:erlang.list_to_binary(code)}"
|
||||
}
|
||||
|
||||
{:error, {:failed_connect, [{:to_address, {host, port}}, {_, _, code}]}} ->
|
||||
message = """
|
||||
Unable to connect to Vite #{host}:#{port}: #{code}
|
||||
|
||||
Ensure Vite is running:
|
||||
cd assets && npx vite
|
||||
|
||||
Or switch back to NodeJS SSR in config/dev.exs:
|
||||
config :live_svelte, ssr_module: LiveSvelte.SSR.NodeJS
|
||||
"""
|
||||
|
||||
raise %LiveSvelte.SSR.NotConfigured{message: message}
|
||||
|
||||
{:error, reason} ->
|
||||
raise %LiveSvelte.SSR.NotConfigured{
|
||||
message: "ViteJS SSR connection error: #{inspect(reason)}"
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns a path relative to the configured Vite JS host.
|
||||
"""
|
||||
def vite_path(path) do
|
||||
case Application.get_env(:live_svelte, :vite_host) do
|
||||
nil ->
|
||||
message = """
|
||||
Vite.js host is not configured. Please add the following to config/dev.exs:
|
||||
|
||||
config :live_svelte, vite_host: "http://localhost:5173"
|
||||
|
||||
and ensure Vite is running.
|
||||
"""
|
||||
|
||||
raise %LiveSvelte.SSR.NotConfigured{message: message}
|
||||
|
||||
host ->
|
||||
Path.join(host, path)
|
||||
end
|
||||
end
|
||||
end
|
||||
2
mix.exs
2
mix.exs
|
|
@ -51,7 +51,7 @@ defmodule LiveSvelte.MixProject do
|
|||
|
||||
def application do
|
||||
[
|
||||
extra_applications: [:logger]
|
||||
extra_applications: [:logger, :inets]
|
||||
]
|
||||
end
|
||||
|
||||
|
|
|
|||
10
package.json
10
package.json
|
|
@ -18,9 +18,13 @@
|
|||
"svelte": "^5.1.13"
|
||||
},
|
||||
"exports": {
|
||||
"types": "./assets/js/live_svelte/types.d.ts",
|
||||
"import": "./priv/static/live_svelte.esm.js",
|
||||
"require": "./priv/static/live_svelte.cjs.js"
|
||||
".": {
|
||||
"svelte": "./assets/js/live_svelte/index.ts",
|
||||
"types": "./assets/js/live_svelte/types.d.ts",
|
||||
"import": "./priv/static/live_svelte.esm.js",
|
||||
"require": "./priv/static/live_svelte.cjs.js"
|
||||
},
|
||||
"./vitePlugin": "./assets/js/live_svelte/vite_plugin.js"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
|
|||
82
test/reload_test.exs
Normal file
82
test/reload_test.exs
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
defmodule LiveSvelte.ReloadTest do
|
||||
# must be synchronous — tests are sensitive to config changes
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
setup do
|
||||
original = Application.get_env(:live_svelte, :vite_host)
|
||||
|
||||
on_exit(fn ->
|
||||
if original == nil do
|
||||
Application.delete_env(:live_svelte, :vite_host)
|
||||
else
|
||||
Application.put_env(:live_svelte, :vite_host, original)
|
||||
end
|
||||
end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
# Renders the vite_assets/1 function component to an HTML string.
|
||||
defp render(assets, inner_block_content) do
|
||||
assigns = %{
|
||||
__changed__: nil,
|
||||
assets: assets,
|
||||
inner_block: [%{inner_block: fn _, _ -> inner_block_content end}]
|
||||
}
|
||||
|
||||
LiveSvelte.Reload.vite_assets(assigns)
|
||||
|> Phoenix.HTML.Safe.to_iodata()
|
||||
|> IO.iodata_to_binary()
|
||||
end
|
||||
|
||||
describe "vite_assets/1" do
|
||||
test "renders Vite dev server scripts when :vite_host is configured" do
|
||||
Application.put_env(:live_svelte, :vite_host, "http://localhost:5173")
|
||||
|
||||
html = render(["/js/app.js"], "fallback")
|
||||
|
||||
assert html =~ ~s|src="http://localhost:5173/@vite/client"|
|
||||
assert html =~ ~s|src="http://localhost:5173/js/app.js"|
|
||||
assert html =~ ~s|type="module"|
|
||||
refute html =~ "fallback"
|
||||
end
|
||||
|
||||
test "renders inner_block when :vite_host is not configured" do
|
||||
Application.delete_env(:live_svelte, :vite_host)
|
||||
|
||||
html = render(["/js/app.js"], "FALLBACK_CONTENT")
|
||||
|
||||
assert html =~ "FALLBACK_CONTENT"
|
||||
refute html =~ "@vite/client"
|
||||
refute html =~ "localhost:5173"
|
||||
end
|
||||
|
||||
test "CSS assets render as link stylesheet tags" do
|
||||
Application.put_env(:live_svelte, :vite_host, "http://localhost:5173")
|
||||
|
||||
html = render(["/css/app.css"], "")
|
||||
|
||||
assert html =~ ~s|rel="stylesheet"|
|
||||
assert html =~ "http://localhost:5173/css/app.css"
|
||||
end
|
||||
|
||||
test "JS assets render as module script tags" do
|
||||
Application.put_env(:live_svelte, :vite_host, "http://localhost:5173")
|
||||
|
||||
html = render(["/js/app.js"], "")
|
||||
|
||||
assert html =~ ~s|type="module"|
|
||||
assert html =~ "http://localhost:5173/js/app.js"
|
||||
end
|
||||
|
||||
test "@vite/client script appears before other assets" do
|
||||
Application.put_env(:live_svelte, :vite_host, "http://localhost:5173")
|
||||
|
||||
html = render(["/js/app.js"], "")
|
||||
|
||||
{client_pos, _} = :binary.match(html, "@vite/client")
|
||||
{app_pos, _} = :binary.match(html, "/js/app.js")
|
||||
assert client_pos < app_pos
|
||||
end
|
||||
end
|
||||
end
|
||||
61
test/ssr_vite_js_test.exs
Normal file
61
test/ssr_vite_js_test.exs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
defmodule LiveSvelte.SSR.ViteJSTest do
|
||||
# must be synchronous — tests are sensitive to config changes
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
setup do
|
||||
original = Application.get_env(:live_svelte, :vite_host)
|
||||
|
||||
on_exit(fn ->
|
||||
if original == nil do
|
||||
Application.delete_env(:live_svelte, :vite_host)
|
||||
else
|
||||
Application.put_env(:live_svelte, :vite_host, original)
|
||||
end
|
||||
end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
describe "vite_path/1" do
|
||||
test "returns correct URL when vite_host is configured" do
|
||||
Application.put_env(:live_svelte, :vite_host, "http://localhost:5173")
|
||||
assert LiveSvelte.SSR.ViteJS.vite_path("/ssr_render") == "http://localhost:5173/ssr_render"
|
||||
end
|
||||
|
||||
test "appends path to host without double slash" do
|
||||
Application.put_env(:live_svelte, :vite_host, "http://localhost:5173")
|
||||
assert LiveSvelte.SSR.ViteJS.vite_path("/foo") == "http://localhost:5173/foo"
|
||||
end
|
||||
|
||||
test "raises NotConfigured when vite_host is not set" do
|
||||
Application.delete_env(:live_svelte, :vite_host)
|
||||
|
||||
assert_raise(LiveSvelte.SSR.NotConfigured, fn ->
|
||||
LiveSvelte.SSR.ViteJS.vite_path("/ssr_render")
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
describe "render/3" do
|
||||
test "raises NotConfigured when vite_host is not configured" do
|
||||
Application.delete_env(:live_svelte, :vite_host)
|
||||
|
||||
assert_raise(LiveSvelte.SSR.NotConfigured, fn ->
|
||||
LiveSvelte.SSR.ViteJS.render("Counter", %{count: 0}, nil)
|
||||
end)
|
||||
end
|
||||
|
||||
test "raises NotConfigured when Vite server is unreachable" do
|
||||
# Use a port that is guaranteed to be closed
|
||||
Application.put_env(:live_svelte, :vite_host, "http://127.0.0.1:59998")
|
||||
|
||||
assert_raise(LiveSvelte.SSR.NotConfigured, fn ->
|
||||
LiveSvelte.SSR.ViteJS.render("Counter", %{count: 0}, nil)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
test "implements LiveSvelte.SSR behaviour" do
|
||||
assert function_exported?(LiveSvelte.SSR.ViteJS, :render, 3)
|
||||
end
|
||||
end
|
||||
Loading…
Reference in a new issue