mirror of
https://github.com/woutdp/live_svelte
synced 2026-05-24 09:28:21 +00:00
chore: added svelte store example
This commit is contained in:
parent
1c96a2d11e
commit
fb1e73c451
9 changed files with 270 additions and 2 deletions
44
example_project/assets/svelte/StoreCounter.svelte
Normal file
44
example_project/assets/svelte/StoreCounter.svelte
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<script lang="ts">
|
||||
import {sharedCount, sharedCountIsOdd} from "./store"
|
||||
|
||||
let {label, live}: {label: string; live: any} = $props()
|
||||
</script>
|
||||
|
||||
<div class="card bg-base-100 shadow-lg border border-base-300/50 h-full">
|
||||
<div class="card-body gap-4 items-center text-center">
|
||||
<span class="badge badge-outline badge-sm font-medium text-base-content/70 w-fit">
|
||||
{label}
|
||||
</span>
|
||||
|
||||
<div class="flex items-center gap-6 py-2">
|
||||
<button
|
||||
class="btn btn-sm btn-outline border-base-300 hover:border-error hover:text-error"
|
||||
data-testid="store-decrement"
|
||||
onclick={() => sharedCount.update(n => n - 1)}
|
||||
>
|
||||
−1
|
||||
</button>
|
||||
|
||||
<span class="text-4xl font-bold tabular-nums text-brand min-w-12" data-testid="store-count">
|
||||
{$sharedCount}
|
||||
</span>
|
||||
|
||||
<button class="btn btn-sm btn-success border-0" data-testid="store-increment" onclick={() => sharedCount.update(n => n + 1)}>
|
||||
+1
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span class="badge badge-sm badge-ghost">{$sharedCountIsOdd ? "Odd" : "Even"}</span>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-xs btn-ghost" data-testid="store-reset" onclick={() => sharedCount.set(0)}> Reset </button>
|
||||
<button
|
||||
class="btn btn-xs bg-brand text-white"
|
||||
data-testid="store-sync"
|
||||
onclick={() => live.pushEvent("sync_store", {value: $sharedCount})}
|
||||
>
|
||||
Sync to server
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
7
example_project/assets/svelte/store.ts
Normal file
7
example_project/assets/svelte/store.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import {writable, derived} from "svelte/store"
|
||||
|
||||
// Shared writable store — a module singleton.
|
||||
// Every component that imports this gets the exact same store instance,
|
||||
// so any write is immediately visible in all subscribers across the page.
|
||||
export const sharedCount = writable(0)
|
||||
export const sharedCountIsOdd = derived(sharedCount, count => count % 2 === 1)
|
||||
|
|
@ -50,6 +50,7 @@ defmodule ExampleWeb.Layouts do
|
|||
%{label: "Client Loading", to: ~p"/live-client-side-loading"},
|
||||
%{label: "Rich Editor (@attach)", to: ~p"/live-editor"},
|
||||
%{label: "Runed Utilities", to: ~p"/live-runed"},
|
||||
%{label: "Svelte Stores", to: ~p"/live-stores"},
|
||||
%{label: "SSR Demo", to: ~p"/live-ssr"}
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -192,6 +192,18 @@
|
|||
</a>
|
||||
<span class="text-base-content/50 text-sm">- Use Editor.js via Svelte's @attach directive</span>
|
||||
</li>
|
||||
<li>
|
||||
<a href={~p"/live-runed"} class="link link-primary">
|
||||
Runed utilites
|
||||
</a>
|
||||
<span class="text-base-content/50 text-sm">- Svelte runed utilities syncing with backend</span>
|
||||
</li>
|
||||
<li>
|
||||
<a href={~p"/live-stores"} class="link link-primary">
|
||||
Svelte stores
|
||||
</a>
|
||||
<span class="text-base-content/50 text-sm">- Svelte writable stores used across components</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
66
example_project/lib/example_web/live/live_stores.ex
Normal file
66
example_project/lib/example_web/live/live_stores.ex
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
defmodule ExampleWeb.LiveStores do
|
||||
use ExampleWeb, :live_view
|
||||
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, assign(socket, server_value: nil, sync_count: 0)}
|
||||
end
|
||||
|
||||
def handle_event("sync_store", %{"value" => value}, socket) do
|
||||
{:noreply, assign(socket, server_value: value, sync_count: socket.assigns.sync_count + 1)}
|
||||
end
|
||||
|
||||
def render(assigns) 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">Svelte Stores</h1>
|
||||
<p class="text-sm text-base-content/50 mb-8 text-center">
|
||||
Two instances of the same component share a single
|
||||
<code class="font-mono">writable</code>
|
||||
store. Clicking +1 in either card instantly updates both — no props, no events, no server round-trip.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-8">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div data-testid="store-instance-1">
|
||||
<.svelte
|
||||
name="StoreCounter"
|
||||
props={%{label: "Instance A"}}
|
||||
socket={@socket}
|
||||
/>
|
||||
</div>
|
||||
<div data-testid="store-instance-2">
|
||||
<.svelte
|
||||
name="StoreCounter"
|
||||
props={%{label: "Instance B"}}
|
||||
socket={@socket}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="card bg-base-100 shadow-lg border border-base-300/50">
|
||||
<div class="card-body gap-4">
|
||||
<span class="badge badge-outline badge-sm font-medium text-base-content/70 w-fit">
|
||||
Server state
|
||||
</span>
|
||||
<p class="text-sm">
|
||||
Last synced value:
|
||||
<strong data-testid="server-value">
|
||||
<%= if @server_value != nil do %>
|
||||
{@server_value}
|
||||
<% else %>
|
||||
not yet synced
|
||||
<% end %>
|
||||
</strong>
|
||||
</p>
|
||||
<p class="text-sm">
|
||||
Sync count: <strong data-testid="sync-count">{@sync_count}</strong>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
@ -49,6 +49,7 @@ defmodule ExampleWeb.Router do
|
|||
live("/live-navigation/:page", LiveNavigation)
|
||||
live("/live-editor", LiveEditor)
|
||||
live("/live-runed", LiveRuned)
|
||||
live("/live-stores", LiveStores)
|
||||
live("/live-ssr", LiveSsr)
|
||||
live("/live-composition", LiveComposition)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -18,12 +18,12 @@
|
|||
"isDynamicEntry": true
|
||||
},
|
||||
"css/app.css": {
|
||||
"file": "assets/app-DwTE1PSy.css",
|
||||
"file": "assets/app-DB4pPYVV.css",
|
||||
"src": "css/app.css",
|
||||
"isEntry": true
|
||||
},
|
||||
"js/app.js": {
|
||||
"file": "assets/app-Bmf8wBN3.js",
|
||||
"file": "assets/app-LUJMG2_W.js",
|
||||
"name": "app",
|
||||
"src": "js/app.js",
|
||||
"isEntry": true,
|
||||
|
|
|
|||
67
example_project/test/example_web/live/live_stores_test.exs
Normal file
67
example_project/test/example_web/live/live_stores_test.exs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
defmodule ExampleWeb.LiveStoresTest do
|
||||
@moduledoc """
|
||||
E2E tests for the /live-stores LiveView with the StoreCounter Svelte component.
|
||||
The key assertion: both component instances share the same writable store —
|
||||
incrementing in one card immediately updates the other.
|
||||
"""
|
||||
use ExampleWeb.FeatureCase, async: false
|
||||
|
||||
@moduletag :e2e
|
||||
|
||||
test "page loads and shows heading", %{session: session} do
|
||||
session
|
||||
|> visit("/live-stores")
|
||||
|> assert_has(Query.css("h1", text: "Svelte Stores"))
|
||||
end
|
||||
|
||||
test "both StoreCounter instances render", %{session: session} do
|
||||
session
|
||||
|> visit("/live-stores")
|
||||
|> assert_has(Query.css("[data-testid='store-instance-1']"))
|
||||
|> assert_has(Query.css("[data-testid='store-instance-2']"))
|
||||
end
|
||||
|
||||
test "both instances show initial count of 0", %{session: session} do
|
||||
session = visit(session, "/live-stores")
|
||||
|
||||
# Wait for Svelte to mount
|
||||
session |> find(Query.css("[data-testid='store-count']", count: 2))
|
||||
|
||||
counts = all(session, Query.css("[data-testid='store-count']"))
|
||||
assert Enum.map(counts, &Wallaby.Element.text/1) == ["0", "0"]
|
||||
end
|
||||
|
||||
test "incrementing in instance 1 updates both components (store is shared)", %{
|
||||
session: session
|
||||
} do
|
||||
session = visit(session, "/live-stores")
|
||||
|
||||
# Wait for both components to mount
|
||||
session |> find(Query.css("[data-testid='store-count']", count: 2))
|
||||
|
||||
# Click +1 inside instance 1 only
|
||||
instance1 = find(session, Query.css("[data-testid='store-instance-1']"))
|
||||
click(instance1, Query.css("[data-testid='store-increment']"))
|
||||
|
||||
# Both counters must now show 1 — the store is shared
|
||||
session |> find(Query.css("[data-testid='store-count']", count: 2, minimum: 2))
|
||||
counts = all(session, Query.css("[data-testid='store-count']"))
|
||||
assert Enum.map(counts, &Wallaby.Element.text/1) == ["1", "1"]
|
||||
end
|
||||
|
||||
test "reset button sets store back to 0 in both components", %{session: session} do
|
||||
session = visit(session, "/live-stores")
|
||||
|
||||
session |> find(Query.css("[data-testid='store-count']", count: 2))
|
||||
|
||||
# Increment via instance 2, then reset via instance 1
|
||||
instance2 = find(session, Query.css("[data-testid='store-instance-2']"))
|
||||
click(instance2, Query.css("[data-testid='store-increment']"))
|
||||
|
||||
instance1 = find(session, Query.css("[data-testid='store-instance-1']"))
|
||||
click(instance1, Query.css("[data-testid='store-reset']"))
|
||||
|
||||
counts = all(session, Query.css("[data-testid='store-count']"))
|
||||
assert Enum.map(counts, &Wallaby.Element.text/1) == ["0", "0"]
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
defmodule ExampleWeb.PhoenixTest.LiveStoresTest do
|
||||
@moduledoc """
|
||||
PhoenixTest (in-process) for LiveStores (/live-stores).
|
||||
Validates server-side rendering, data-props contract, and sync_store event handling.
|
||||
Store sharing between instances is client-only; these tests validate the LiveView layer.
|
||||
"""
|
||||
use ExampleWeb.ConnCase, async: false
|
||||
import PhoenixTest
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
@moduletag :phoenix_test
|
||||
|
||||
test "renders page heading", %{conn: conn} do
|
||||
conn
|
||||
|> visit("/live-stores")
|
||||
|> assert_has("h1", text: "Svelte Stores")
|
||||
end
|
||||
|
||||
test "renders two StoreCounter components", %{conn: conn} do
|
||||
conn
|
||||
|> visit("/live-stores")
|
||||
|> assert_has("[data-name='StoreCounter']", count: 2)
|
||||
end
|
||||
|
||||
test "both components receive label prop", %{conn: conn} do
|
||||
conn
|
||||
|> visit("/live-stores")
|
||||
|> assert_has("[data-props*='Instance A']")
|
||||
|> assert_has("[data-props*='Instance B']")
|
||||
end
|
||||
|
||||
test "server value shows not yet synced initially", %{conn: conn} do
|
||||
conn
|
||||
|> visit("/live-stores")
|
||||
|> assert_has("[data-testid='server-value']", text: "not yet synced")
|
||||
end
|
||||
|
||||
test "initial sync count is 0", %{conn: conn} do
|
||||
conn
|
||||
|> visit("/live-stores")
|
||||
|> assert_has("[data-testid='sync-count']", text: "0")
|
||||
end
|
||||
|
||||
test "sync_store event updates server value", %{conn: conn} do
|
||||
conn
|
||||
|> visit("/live-stores")
|
||||
|> unwrap(fn view ->
|
||||
render_click(view, "sync_store", %{"value" => 42})
|
||||
end)
|
||||
|> assert_has("[data-testid='server-value']", text: "42")
|
||||
end
|
||||
|
||||
test "sync_store event increments sync count", %{conn: conn} do
|
||||
conn
|
||||
|> visit("/live-stores")
|
||||
|> unwrap(fn view -> render_click(view, "sync_store", %{"value" => 1}) end)
|
||||
|> assert_has("[data-testid='sync-count']", text: "1")
|
||||
|> unwrap(fn view -> render_click(view, "sync_store", %{"value" => 2}) end)
|
||||
|> assert_has("[data-testid='sync-count']", text: "2")
|
||||
end
|
||||
|
||||
test "sync_store event replaces previous server value", %{conn: conn} do
|
||||
conn
|
||||
|> visit("/live-stores")
|
||||
|> unwrap(fn view -> render_click(view, "sync_store", %{"value" => 5}) end)
|
||||
|> assert_has("[data-testid='server-value']", text: "5")
|
||||
|> unwrap(fn view -> render_click(view, "sync_store", %{"value" => 99}) end)
|
||||
|> assert_has("[data-testid='server-value']", text: "99")
|
||||
end
|
||||
end
|
||||
Loading…
Reference in a new issue