mirror of
https://github.com/woutdp/live_svelte
synced 2026-05-24 09:28:21 +00:00
chore: add live form support via ecto changesets
This commit is contained in:
parent
434e362ce0
commit
2ab54b8690
24 changed files with 3120 additions and 9 deletions
261
assets/js/live_svelte/composables.test.ts
Normal file
261
assets/js/live_svelte/composables.test.ts
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
/**
|
||||
* Unit tests for LiveSvelte composables.
|
||||
* Uses vi.mock to intercept getContext/onDestroy calls (called during component init).
|
||||
*/
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest"
|
||||
|
||||
// Mock svelte BEFORE importing composables (hoisted by vitest)
|
||||
vi.mock("svelte", () => ({
|
||||
getContext: vi.fn(),
|
||||
onDestroy: vi.fn(),
|
||||
}))
|
||||
|
||||
import { getContext, onDestroy } from "svelte"
|
||||
import {
|
||||
useLiveSvelte,
|
||||
useLiveEvent,
|
||||
useLiveConnection,
|
||||
useLiveNavigation,
|
||||
LIVE_SYMBOL,
|
||||
CONNECTION_SYMBOL,
|
||||
} from "./composables"
|
||||
import type { Live } from "./types"
|
||||
|
||||
const mockLiveSocket = {
|
||||
pushHistoryPatch: vi.fn(),
|
||||
historyRedirect: vi.fn(),
|
||||
}
|
||||
|
||||
const mockLive: Live = {
|
||||
pushEvent: vi.fn().mockReturnValue(1),
|
||||
pushEventTo: vi.fn().mockReturnValue(2),
|
||||
handleEvent: vi.fn().mockReturnValue(() => {}),
|
||||
removeHandleEvent: vi.fn(),
|
||||
upload: vi.fn(),
|
||||
uploadTo: vi.fn(),
|
||||
liveSocket: mockLiveSocket,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useLiveSvelte
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("useLiveSvelte", () => {
|
||||
it("returns live ref from context", () => {
|
||||
vi.mocked(getContext).mockReturnValue(mockLive)
|
||||
const result = useLiveSvelte()
|
||||
expect(getContext).toHaveBeenCalledWith(LIVE_SYMBOL)
|
||||
expect(result.live).toBe(mockLive)
|
||||
})
|
||||
|
||||
it("throws when context is not set", () => {
|
||||
vi.mocked(getContext).mockReturnValue(undefined)
|
||||
expect(() => useLiveSvelte()).toThrow("useLiveSvelte()")
|
||||
})
|
||||
|
||||
it("pushEvent delegates to live.pushEvent", () => {
|
||||
vi.mocked(getContext).mockReturnValue(mockLive)
|
||||
const result = useLiveSvelte()
|
||||
result.pushEvent("my_event", { key: "val" })
|
||||
expect(mockLive.pushEvent).toHaveBeenCalledWith("my_event", { key: "val" })
|
||||
})
|
||||
|
||||
it("pushEventTo delegates to live.pushEventTo", () => {
|
||||
vi.mocked(getContext).mockReturnValue(mockLive)
|
||||
const result = useLiveSvelte()
|
||||
result.pushEventTo("#target", "my_event", { key: "val" })
|
||||
expect(mockLive.pushEventTo).toHaveBeenCalledWith("#target", "my_event", { key: "val" })
|
||||
})
|
||||
|
||||
it("pushEvent returns ref number from live.pushEvent", () => {
|
||||
vi.mocked(mockLive.pushEvent).mockReturnValue(42)
|
||||
vi.mocked(getContext).mockReturnValue(mockLive)
|
||||
const result = useLiveSvelte()
|
||||
const ref = result.pushEvent("evt", {})
|
||||
expect(ref).toBe(42)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useLiveEvent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("useLiveEvent", () => {
|
||||
it("throws when called outside LiveSvelte context", () => {
|
||||
vi.mocked(getContext).mockReturnValue(undefined)
|
||||
expect(() => useLiveEvent("some_event", vi.fn())).toThrow("useLiveEvent()")
|
||||
})
|
||||
|
||||
it("calls live.handleEvent with the event name and callback", () => {
|
||||
vi.mocked(getContext).mockReturnValue(mockLive)
|
||||
const cb = vi.fn()
|
||||
useLiveEvent("server_event", cb)
|
||||
expect(mockLive.handleEvent).toHaveBeenCalledWith("server_event", cb)
|
||||
})
|
||||
|
||||
it("registers cleanup via onDestroy", () => {
|
||||
const cleanupFn = vi.fn()
|
||||
vi.mocked(mockLive.handleEvent).mockReturnValue(cleanupFn)
|
||||
vi.mocked(getContext).mockReturnValue(mockLive)
|
||||
useLiveEvent("server_event", vi.fn())
|
||||
expect(onDestroy).toHaveBeenCalledWith(cleanupFn)
|
||||
})
|
||||
|
||||
it("cleanup fn returned by handleEvent is passed to onDestroy", () => {
|
||||
const cleanup = vi.fn()
|
||||
vi.mocked(mockLive.handleEvent).mockReturnValue(cleanup)
|
||||
vi.mocked(getContext).mockReturnValue(mockLive)
|
||||
useLiveEvent("server_event", vi.fn())
|
||||
const registeredCleanup = vi.mocked(onDestroy).mock.calls[0][0] as () => void
|
||||
registeredCleanup()
|
||||
expect(cleanup).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useLiveConnection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("useLiveConnection", () => {
|
||||
it("reads connected from context state", () => {
|
||||
const fakeState = { connected: true }
|
||||
vi.mocked(getContext).mockImplementation((key) => {
|
||||
if (key === CONNECTION_SYMBOL) return fakeState
|
||||
return undefined
|
||||
})
|
||||
const conn = useLiveConnection()
|
||||
expect(conn.connected).toBe(true)
|
||||
})
|
||||
|
||||
it("reflects state changes via getter", () => {
|
||||
const fakeState = { connected: true }
|
||||
vi.mocked(getContext).mockImplementation((key) => {
|
||||
if (key === CONNECTION_SYMBOL) return fakeState
|
||||
return undefined
|
||||
})
|
||||
const conn = useLiveConnection()
|
||||
fakeState.connected = false
|
||||
expect(conn.connected).toBe(false)
|
||||
})
|
||||
|
||||
it("returns connected: true when no context present (fallback)", () => {
|
||||
vi.mocked(getContext).mockReturnValue(undefined)
|
||||
const conn = useLiveConnection()
|
||||
expect(conn.connected).toBe(true)
|
||||
})
|
||||
|
||||
it("calls getContext with CONNECTION_SYMBOL", () => {
|
||||
vi.mocked(getContext).mockReturnValue(undefined)
|
||||
useLiveConnection()
|
||||
expect(getContext).toHaveBeenCalledWith(CONNECTION_SYMBOL)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useLiveNavigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("useLiveNavigation", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("location", { pathname: "/current-path" })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it("throws when live context is absent", () => {
|
||||
vi.mocked(getContext).mockReturnValue(undefined)
|
||||
expect(() => useLiveNavigation()).toThrow("useLiveNavigation()")
|
||||
})
|
||||
|
||||
it("throws when liveSocket is not available", () => {
|
||||
const liveWithoutSocket: Live = { ...mockLive, liveSocket: undefined }
|
||||
vi.mocked(getContext).mockReturnValue(liveWithoutSocket)
|
||||
expect(() => useLiveNavigation()).toThrow("LiveSocket not initialized")
|
||||
})
|
||||
|
||||
describe("patch()", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getContext).mockReturnValue(mockLive)
|
||||
})
|
||||
|
||||
it("calls pushHistoryPatch with string href and push by default", () => {
|
||||
const { patch } = useLiveNavigation()
|
||||
patch("/new-path")
|
||||
expect(mockLiveSocket.pushHistoryPatch).toHaveBeenCalledWith(
|
||||
expect.any(Event),
|
||||
"/new-path",
|
||||
"push",
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
it("calls pushHistoryPatch with replace kind when replace: true", () => {
|
||||
const { patch } = useLiveNavigation()
|
||||
patch("/new-path", { replace: true })
|
||||
expect(mockLiveSocket.pushHistoryPatch).toHaveBeenCalledWith(
|
||||
expect.any(Event),
|
||||
"/new-path",
|
||||
"replace",
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
it("builds href from query params object using current pathname", () => {
|
||||
const { patch } = useLiveNavigation()
|
||||
patch({ page: "2", filter: "active" })
|
||||
expect(mockLiveSocket.pushHistoryPatch).toHaveBeenCalledWith(
|
||||
expect.any(Event),
|
||||
"/current-path?page=2&filter=active",
|
||||
"push",
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
it("builds href from query params object with replace option", () => {
|
||||
const { patch } = useLiveNavigation()
|
||||
patch({ search: "test" }, { replace: true })
|
||||
expect(mockLiveSocket.pushHistoryPatch).toHaveBeenCalledWith(
|
||||
expect.any(Event),
|
||||
"/current-path?search=test",
|
||||
"replace",
|
||||
null
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("navigate()", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getContext).mockReturnValue(mockLive)
|
||||
})
|
||||
|
||||
it("calls historyRedirect with href and push by default", () => {
|
||||
const { navigate } = useLiveNavigation()
|
||||
navigate("/other-live-view")
|
||||
expect(mockLiveSocket.historyRedirect).toHaveBeenCalledWith(
|
||||
expect.any(Event),
|
||||
"/other-live-view",
|
||||
"push",
|
||||
null,
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
it("calls historyRedirect with replace kind when replace: true", () => {
|
||||
const { navigate } = useLiveNavigation()
|
||||
navigate("/other-live-view", { replace: true })
|
||||
expect(mockLiveSocket.historyRedirect).toHaveBeenCalledWith(
|
||||
expect.any(Event),
|
||||
"/other-live-view",
|
||||
"replace",
|
||||
null,
|
||||
null
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
191
assets/js/live_svelte/composables.ts
Normal file
191
assets/js/live_svelte/composables.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
/**
|
||||
* LiveSvelte composables — ergonomic access to the Phoenix hook context
|
||||
* from any Svelte component mounted by SvelteHook, without prop drilling.
|
||||
*
|
||||
* Context architecture:
|
||||
* The SvelteHook passes live ref and connection state via Svelte's context
|
||||
* system (mount/hydrate `context` option). Composables read from that context.
|
||||
*
|
||||
* Backward compatibility:
|
||||
* The `live` prop is still passed to Svelte components for existing code.
|
||||
* Composables are an additive ergonomics improvement.
|
||||
*/
|
||||
|
||||
import { getContext, onDestroy } from "svelte"
|
||||
import type { Live, LiveSocket, UseLiveNavigationResult } from "./types"
|
||||
|
||||
/** Context key for the Phoenix hook reference (`this` inside SvelteHook). */
|
||||
export const LIVE_SYMBOL = Symbol("__live_svelte__")
|
||||
|
||||
/**
|
||||
* Context key for the reactive connection state object.
|
||||
* The SvelteHook sets this to `{ connected: boolean }` and updates it
|
||||
* via its `disconnected()` / `reconnected()` lifecycle callbacks.
|
||||
*/
|
||||
export const CONNECTION_SYMBOL = Symbol("__live_svelte_connection__")
|
||||
|
||||
/** Shape of the connection state object shared via context. */
|
||||
export interface LiveConnectionState {
|
||||
connected: boolean
|
||||
}
|
||||
|
||||
/** Return type of useLiveSvelte(). */
|
||||
export interface UseLiveSvelteResult {
|
||||
/** The raw Phoenix hook context. Prefer pushEvent/pushEventTo for type safety. */
|
||||
readonly live: Live
|
||||
pushEvent: Live["pushEvent"]
|
||||
pushEventTo: Live["pushEventTo"]
|
||||
}
|
||||
|
||||
/** Return type of useLiveConnection(). */
|
||||
export interface UseLiveConnectionResult {
|
||||
/** `true` when the Phoenix WebSocket is connected, `false` when disconnected. */
|
||||
readonly connected: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Access the Phoenix hook context from any Svelte component mounted by SvelteHook.
|
||||
*
|
||||
* @throws {Error} When called outside a LiveSvelte-mounted component tree.
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <script>
|
||||
* import { useLiveSvelte } from "live_svelte"
|
||||
* const { pushEvent } = useLiveSvelte()
|
||||
* function save() { pushEvent("save", { value }) }
|
||||
* </script>
|
||||
* ```
|
||||
*/
|
||||
export function useLiveSvelte(): UseLiveSvelteResult {
|
||||
const live = getContext<Live>(LIVE_SYMBOL)
|
||||
if (!live) {
|
||||
if (typeof window === "undefined") {
|
||||
const noop = () => {}
|
||||
return { live: null as unknown as Live, pushEvent: noop as Live["pushEvent"], pushEventTo: noop as Live["pushEventTo"] }
|
||||
}
|
||||
throw new Error("useLiveSvelte() must be called inside a LiveSvelte-mounted component")
|
||||
}
|
||||
return {
|
||||
get live() {
|
||||
return live
|
||||
},
|
||||
pushEvent: (...args: Parameters<Live["pushEvent"]>) => live.pushEvent(...args),
|
||||
pushEventTo: (...args: Parameters<Live["pushEventTo"]>) => live.pushEventTo(...args),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a server-sent LiveView event with automatic cleanup on component destroy.
|
||||
*
|
||||
* Calls `live.handleEvent(event, callback)` and registers the returned cleanup
|
||||
* function with `onDestroy` — no manual `removeHandleEvent` needed.
|
||||
*
|
||||
* @note Calling `useLiveEvent` multiple times with the same event name registers
|
||||
* multiple independent subscriptions — all callbacks fire on each event.
|
||||
* This is intentional; deduplicate in the caller if needed.
|
||||
*
|
||||
* @throws {Error} When called outside a LiveSvelte-mounted component tree.
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <script>
|
||||
* import { useLiveEvent } from "live_svelte"
|
||||
* useLiveEvent("item_added", (payload) => { console.log(payload) })
|
||||
* </script>
|
||||
* ```
|
||||
*/
|
||||
export function useLiveEvent(event: string, callback: (payload: unknown) => void): void {
|
||||
const live = getContext<Live>(LIVE_SYMBOL)
|
||||
if (!live) {
|
||||
if (typeof window === "undefined") return
|
||||
throw new Error("useLiveEvent() must be called inside a LiveSvelte-mounted component")
|
||||
}
|
||||
const cleanup = live.handleEvent(event, callback)
|
||||
onDestroy(cleanup)
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe the Phoenix WebSocket connection status as a reactive value.
|
||||
*
|
||||
* Connection state is managed by the SvelteHook via `disconnected()` /
|
||||
* `reconnected()` lifecycle callbacks. Returns `{ connected: true }` when
|
||||
* called outside a LiveSvelte context (e.g. SSR, tests).
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <script>
|
||||
* import { useLiveConnection } from "live_svelte"
|
||||
* const conn = useLiveConnection()
|
||||
* </script>
|
||||
* {#if !conn.connected}
|
||||
* <p>Reconnecting…</p>
|
||||
* {/if}
|
||||
* ```
|
||||
*/
|
||||
export function useLiveConnection(): UseLiveConnectionResult {
|
||||
const state = getContext<LiveConnectionState>(CONNECTION_SYMBOL)
|
||||
if (!state) {
|
||||
// Outside LiveSvelte context — assume connected (SSR, unit testing)
|
||||
return { connected: true }
|
||||
}
|
||||
return {
|
||||
get connected() {
|
||||
return state.connected
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side LiveView navigation from a Svelte component.
|
||||
*
|
||||
* Uses the `liveSocket` instance on the Phoenix hook context to perform
|
||||
* navigation without full page reloads. Mirrors LiveVue's `useLiveNavigation`.
|
||||
*
|
||||
* - `patch()` patches the current LiveView (triggers `handle_params/3`).
|
||||
* - `navigate()` mounts a new LiveView process (within the same live_session).
|
||||
* - Both accept `{ replace: true }` to use `history.replaceState`.
|
||||
*
|
||||
* @throws {Error} When called outside a LiveSvelte-mounted component tree.
|
||||
* @throws {Error} When `live.liveSocket` is not initialized.
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <script>
|
||||
* import { useLiveNavigation } from "live_svelte"
|
||||
* const { patch, navigate } = useLiveNavigation()
|
||||
* </script>
|
||||
* <button onclick={() => patch("?page=2")}>Next page</button>
|
||||
* <button onclick={() => navigate("/other-view")}>Go elsewhere</button>
|
||||
* ```
|
||||
*/
|
||||
export function useLiveNavigation(): UseLiveNavigationResult {
|
||||
const live = getContext<Live>(LIVE_SYMBOL)
|
||||
if (!live) {
|
||||
if (typeof window === "undefined") return { patch: () => {}, navigate: () => {} }
|
||||
throw new Error("useLiveNavigation() must be called inside a LiveSvelte-mounted component")
|
||||
}
|
||||
const liveSocket = live.liveSocket as LiveSocket | undefined
|
||||
if (!liveSocket) throw new Error("LiveSocket not initialized")
|
||||
|
||||
const patch = (
|
||||
hrefOrQueryParams: string | Record<string, string>,
|
||||
opts: { replace?: boolean } = {}
|
||||
): void => {
|
||||
let href =
|
||||
typeof hrefOrQueryParams === "string"
|
||||
? hrefOrQueryParams
|
||||
: globalThis.location.pathname
|
||||
if (typeof hrefOrQueryParams === "object") {
|
||||
const queryParams = new URLSearchParams(hrefOrQueryParams)
|
||||
href = `${href}?${queryParams.toString()}`
|
||||
}
|
||||
liveSocket.pushHistoryPatch(new Event("click"), href, opts.replace ? "replace" : "push", null)
|
||||
}
|
||||
|
||||
const navigate = (href: string, opts: { replace?: boolean } = {}): void => {
|
||||
liveSocket.historyRedirect(new Event("click"), href, opts.replace ? "replace" : "push", null, null)
|
||||
}
|
||||
|
||||
return { patch, navigate }
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { decodeB64ToUTF8, normalizeComponents } from "./utils"
|
||||
import { mount, hydrate, unmount, createRawSnippet } from "svelte"
|
||||
import { applyPatch } from "./jsonPatch.js"
|
||||
import { LIVE_SYMBOL, CONNECTION_SYMBOL } from "./composables"
|
||||
|
||||
function getAttributeJson(ref, attributeName) {
|
||||
const data = ref.el.getAttribute(attributeName)
|
||||
|
|
@ -116,6 +117,8 @@ export function getHooks(components) {
|
|||
const SvelteHook = {
|
||||
mounted() {
|
||||
let state = $state(getProps(this))
|
||||
let connectionState = $state({ connected: true })
|
||||
|
||||
const componentName = this.el.getAttribute("data-name")
|
||||
if (!componentName) throw new Error("Component name must be provided")
|
||||
|
||||
|
|
@ -140,8 +143,10 @@ export function getHooks(components) {
|
|||
this._instance = hydrateOrMount(Component, {
|
||||
target,
|
||||
props: state,
|
||||
context: new Map([[LIVE_SYMBOL, this], [CONNECTION_SYMBOL, connectionState]]),
|
||||
})
|
||||
this._instance.state = state
|
||||
this._instance.connectionState = connectionState
|
||||
|
||||
// Apply initial stream items from data-streams-diff
|
||||
const initialStreamsDiff = getDiff(this, "data-streams-diff")
|
||||
|
|
@ -154,6 +159,23 @@ export function getHooks(components) {
|
|||
update_state(this)
|
||||
},
|
||||
|
||||
// Updates the reactive connection state shared via Svelte context with
|
||||
// useLiveConnection(). Unit-testing these is impractical (requires Svelte
|
||||
// $state outside a component context); correctness is validated by
|
||||
// the Chat.svelte `data-testid="chat-reconnecting"` E2E happy-path test
|
||||
// and by the Chat page remaining functional through connect/disconnect cycles.
|
||||
disconnected() {
|
||||
if (this._instance?.connectionState) {
|
||||
this._instance.connectionState.connected = false
|
||||
}
|
||||
},
|
||||
|
||||
reconnected() {
|
||||
if (this._instance?.connectionState) {
|
||||
this._instance.connectionState.connected = true
|
||||
}
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
if (this._instance) window.addEventListener("phx:page-loading-stop", () => unmount(this._instance), { once: true })
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,2 +1,17 @@
|
|||
export { getRender } from "./render";
|
||||
export { getHooks } from "./hooks.svelte";
|
||||
export { useLiveSvelte, useLiveEvent, useLiveConnection, useLiveNavigation } from "./composables";
|
||||
export type { UseLiveSvelteResult, UseLiveConnectionResult, LiveConnectionState, UseLiveNavigationResult } from "./composables";
|
||||
export { default as Link } from "./Link.svelte";
|
||||
export { useLiveForm } from "./useLiveForm";
|
||||
export type {
|
||||
Form,
|
||||
FormErrors,
|
||||
FormOptions,
|
||||
FieldOptions,
|
||||
FieldState,
|
||||
FieldAttrs,
|
||||
FormField,
|
||||
FormFieldArray,
|
||||
UseLiveFormReturn,
|
||||
} from "./useLiveForm";
|
||||
|
|
|
|||
|
|
@ -79,6 +79,12 @@ describe("$$dom_id path syntax (remove / replace via dom_id)", () => {
|
|||
expect(items[0].name).toBe("Updated")
|
||||
expect(items.length).toBe(2)
|
||||
})
|
||||
|
||||
it("replace with unknown $$dom_id is silently skipped (update_only: true, item absent)", () => {
|
||||
const state: Record<string, unknown> = { items: [makeItem(1)] }
|
||||
applyPatch(state, [["replace", "/items/$$items-999", makeItem(999, { name: "Ghost" })]])
|
||||
expect((state.items as { id: number }[]).map((i) => i.id)).toEqual([1])
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
196
assets/js/live_svelte/types.d.ts
vendored
196
assets/js/live_svelte/types.d.ts
vendored
|
|
@ -3,6 +3,15 @@
|
|||
* No `any` in public API; consumers get type safety and exported types.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Minimal LiveSocket interface for navigation composable.
|
||||
* Exposes only the methods needed for patch/navigate.
|
||||
*/
|
||||
export interface LiveSocket {
|
||||
pushHistoryPatch(event: Event, href: string, kind: "push" | "replace", target: Element | null): void;
|
||||
historyRedirect(event: Event, href: string, kind: "push" | "replace", flash: unknown, callback: (() => void) | null): void;
|
||||
}
|
||||
|
||||
/** LiveView socket/channel push and event types (payloads from server are unknown) */
|
||||
export type Live = {
|
||||
pushEvent(
|
||||
|
|
@ -22,6 +31,9 @@ export type Live = {
|
|||
|
||||
upload(name: string, files: FileList | File[]): void;
|
||||
uploadTo(phxTarget: unknown, name: string, files: FileList | File[]): void;
|
||||
|
||||
/** The Phoenix LiveSocket instance — available on the hook `this` context. */
|
||||
liveSocket?: LiveSocket;
|
||||
};
|
||||
|
||||
/** Component module input: default = modules, filenames = paths */
|
||||
|
|
@ -57,3 +69,187 @@ export declare const getHooks: (components: ComponentModuleInput) => SvelteHooks
|
|||
export declare const getRender: (
|
||||
components: ComponentModuleInput
|
||||
) => LiveSvelteRenderFn;
|
||||
|
||||
/**
|
||||
* Shape of the reactive connection-state object shared via Svelte context.
|
||||
* Keep in sync with the `LiveConnectionState` interface in composables.ts.
|
||||
*/
|
||||
export interface LiveConnectionState {
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
/** Return type of useLiveSvelte() */
|
||||
export interface UseLiveSvelteResult {
|
||||
readonly live: Live;
|
||||
pushEvent(event: string, payload?: object, onReply?: (reply: unknown, ref: number) => void): number;
|
||||
pushEventTo(phxTarget: unknown, event: string, payload?: object, onReply?: (reply: unknown, ref: number) => void): number;
|
||||
}
|
||||
|
||||
/** Return type of useLiveConnection() */
|
||||
export interface UseLiveConnectionResult {
|
||||
readonly connected: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Access the Phoenix hook context from any Svelte component mounted by SvelteHook.
|
||||
* Returns pushEvent, pushEventTo, and the raw live ref.
|
||||
* @throws {Error} when called outside a LiveSvelte-mounted component tree
|
||||
*/
|
||||
export declare function useLiveSvelte(): UseLiveSvelteResult;
|
||||
|
||||
/**
|
||||
* Subscribe to a server-sent LiveView event with automatic cleanup on component destroy.
|
||||
* Calls live.handleEvent and registers the cleanup via onDestroy.
|
||||
* @throws {Error} when called outside a LiveSvelte-mounted component tree
|
||||
*/
|
||||
export declare function useLiveEvent(event: string, callback: (payload: unknown) => void): void;
|
||||
|
||||
/**
|
||||
* Observe the Phoenix WebSocket connection status.
|
||||
* Returns { connected } which is true when connected, false when disconnected.
|
||||
* Falls back to { connected: true } outside a LiveSvelte-mounted component.
|
||||
*/
|
||||
export declare function useLiveConnection(): UseLiveConnectionResult;
|
||||
|
||||
/** Return type of useLiveNavigation() */
|
||||
export interface UseLiveNavigationResult {
|
||||
/** Patch the current LiveView — updates URL and triggers handle_params without a full reload. */
|
||||
patch(hrefOrQueryParams: string | Record<string, string>, opts?: { replace?: boolean }): void;
|
||||
/** Navigate to a new LiveView — mounts a new LV process without a full page reload. */
|
||||
navigate(href: string, opts?: { replace?: boolean }): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side LiveView navigation from a Svelte component.
|
||||
*
|
||||
* `patch()` updates the current LiveView (calls handle_params).
|
||||
* `navigate()` mounts a new LiveView process.
|
||||
* Both support `{ replace: true }` to use replaceState instead of pushState.
|
||||
*
|
||||
* @throws {Error} when called outside a LiveSvelte-mounted component tree
|
||||
* @throws {Error} when LiveSocket is not initialized
|
||||
*/
|
||||
export declare function useLiveNavigation(): UseLiveNavigationResult;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useLiveForm types
|
||||
// Keep in sync with useLiveForm.ts — that file is the source of truth.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import type { Readable } from "svelte/store";
|
||||
|
||||
/**
|
||||
* Recursive type for Ecto changeset error maps.
|
||||
* Each key maps to either an array of error strings or a nested error map.
|
||||
*/
|
||||
export type FormErrors<T extends object> = {
|
||||
[K in keyof T]?: string[] | Record<string, unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shape of a Phoenix.HTML.Form encoded by LiveSvelte.Encoder.
|
||||
* Produced by `to_form(changeset)` in the LiveView and passed as a prop.
|
||||
*/
|
||||
export interface Form<T extends object> {
|
||||
name: string;
|
||||
values: T;
|
||||
errors: FormErrors<T>;
|
||||
valid: boolean;
|
||||
}
|
||||
|
||||
/** Options for `field()` to specify input type and checkbox value. */
|
||||
export interface FieldOptions {
|
||||
type?: string;
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
/** Options for `useLiveForm`. */
|
||||
export interface FormOptions {
|
||||
changeEvent?: string | null;
|
||||
submitEvent?: string;
|
||||
debounceInMiliseconds?: number;
|
||||
prepareData?: (data: Record<string, unknown>) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Attributes to spread onto an `<input>` element. */
|
||||
export interface FieldAttrs {
|
||||
name: string;
|
||||
id: string;
|
||||
oninput: (e: Event) => void;
|
||||
onblur: () => void;
|
||||
"aria-invalid": boolean;
|
||||
"aria-describedby"?: string;
|
||||
value?: unknown;
|
||||
checked?: boolean;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
/** Reactive state snapshot for a single form field (the store's value). */
|
||||
export interface FieldState<V> {
|
||||
value: V;
|
||||
errors: string[];
|
||||
errorMessage: string | undefined;
|
||||
isValid: boolean;
|
||||
isDirty: boolean;
|
||||
isTouched: boolean;
|
||||
attrs: FieldAttrs;
|
||||
}
|
||||
|
||||
/**
|
||||
* A reactive field store. Subscribe via `$nameField` in Svelte templates.
|
||||
* Call `nameField.set(value)` to update the field value programmatically.
|
||||
*/
|
||||
export interface FormField<V> extends Readable<FieldState<V>> {
|
||||
set(value: V): void;
|
||||
update(updater: (currentValue: V) => V): void;
|
||||
field(subPath: string, options?: FieldOptions): FormField<any>;
|
||||
fieldArray(subPath: string): FormFieldArray<any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A reactive array-field store with per-item field stores and
|
||||
* add/remove/move operations.
|
||||
*/
|
||||
export interface FormFieldArray<V> extends Readable<FieldState<V[]>> {
|
||||
set(value: V[]): void;
|
||||
fields: Readable<FormField<V>[]>;
|
||||
add(item?: Partial<V>): void;
|
||||
remove(index: number): void;
|
||||
move(from: number, to: number): void;
|
||||
}
|
||||
|
||||
/** Return value of `useLiveForm`. */
|
||||
export interface UseLiveFormReturn<T extends object> {
|
||||
isValid: Readable<boolean>;
|
||||
isDirty: Readable<boolean>;
|
||||
isTouched: Readable<boolean>;
|
||||
isValidating: Readable<boolean>;
|
||||
submitCount: Readable<number>;
|
||||
initialValues: Readonly<T>;
|
||||
field<V = unknown>(path: string, options?: FieldOptions): FormField<V>;
|
||||
fieldArray<V = unknown>(path: string): FormFieldArray<V>;
|
||||
submit(): Promise<any>;
|
||||
reset(): void;
|
||||
sync(newForm: Form<T>): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind to an Ecto changeset form prop with reactive field instances.
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <script lang="ts">
|
||||
* import { useLiveForm } from "live_svelte"
|
||||
* let { form: serverForm } = $props()
|
||||
* const liveForm = useLiveForm(serverForm, { changeEvent: "validate", submitEvent: "submit" })
|
||||
* $effect(() => { liveForm.sync(serverForm) })
|
||||
* const nameField = liveForm.field("name")
|
||||
* </script>
|
||||
* <input {...$nameField.attrs} />
|
||||
* ```
|
||||
*/
|
||||
export declare function useLiveForm<T extends object>(
|
||||
form: Form<T>,
|
||||
options?: FormOptions
|
||||
): UseLiveFormReturn<T>;
|
||||
|
||||
|
|
|
|||
782
assets/js/live_svelte/useLiveForm.test.ts
Normal file
782
assets/js/live_svelte/useLiveForm.test.ts
Normal file
|
|
@ -0,0 +1,782 @@
|
|||
/**
|
||||
* Unit tests for useLiveForm composable.
|
||||
* Uses vi.mock to intercept getContext calls (called inside useLiveSvelte).
|
||||
* Uses `get(store)` from svelte/store for synchronous store reads.
|
||||
*/
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest"
|
||||
|
||||
// Mock svelte BEFORE importing composables (hoisted by vitest).
|
||||
vi.mock("svelte", () => ({
|
||||
getContext: vi.fn(),
|
||||
onDestroy: vi.fn(),
|
||||
}))
|
||||
|
||||
import { get } from "svelte/store"
|
||||
import { getContext } from "svelte"
|
||||
import { useLiveForm } from "./useLiveForm"
|
||||
import type { Form } from "./useLiveForm"
|
||||
import type { Live } from "./types"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockPushEvent = vi.fn().mockReturnValue(1)
|
||||
const mockLive: Live = {
|
||||
pushEvent: mockPushEvent,
|
||||
pushEventTo: vi.fn().mockReturnValue(2),
|
||||
handleEvent: vi.fn().mockReturnValue(() => {}),
|
||||
removeHandleEvent: vi.fn(),
|
||||
upload: vi.fn(),
|
||||
uploadTo: vi.fn(),
|
||||
liveSocket: undefined,
|
||||
}
|
||||
|
||||
const testForm: Form<{ name: string; email: string }> = {
|
||||
name: "user",
|
||||
values: { name: "", email: "" },
|
||||
errors: {},
|
||||
valid: true,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(getContext).mockReturnValue(mockLive)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Initialization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("useLiveForm — initialization", () => {
|
||||
it("returns required API surface", () => {
|
||||
const form = useLiveForm(testForm)
|
||||
expect(form.field).toBeDefined()
|
||||
expect(form.fieldArray).toBeDefined()
|
||||
expect(form.submit).toBeDefined()
|
||||
expect(form.reset).toBeDefined()
|
||||
expect(form.sync).toBeDefined()
|
||||
expect(form.isValid).toBeDefined()
|
||||
expect(form.isDirty).toBeDefined()
|
||||
expect(form.isTouched).toBeDefined()
|
||||
expect(form.isValidating).toBeDefined()
|
||||
expect(form.submitCount).toBeDefined()
|
||||
expect(form.initialValues).toBeDefined()
|
||||
})
|
||||
|
||||
it("exposes a frozen initialValues snapshot", () => {
|
||||
const form = useLiveForm({ ...testForm, values: { name: "Alice", email: "a@b.com" } })
|
||||
expect(form.initialValues.name).toBe("Alice")
|
||||
expect(form.initialValues.email).toBe("a@b.com")
|
||||
expect(Object.isFrozen(form.initialValues)).toBe(true)
|
||||
})
|
||||
|
||||
it("isValid is true when no errors", () => {
|
||||
const form = useLiveForm(testForm)
|
||||
expect(get(form.isValid)).toBe(true)
|
||||
})
|
||||
|
||||
it("isValid is false when errors present", () => {
|
||||
const form = useLiveForm({
|
||||
...testForm,
|
||||
errors: { name: ["can't be blank"] },
|
||||
valid: false,
|
||||
})
|
||||
expect(get(form.isValid)).toBe(false)
|
||||
})
|
||||
|
||||
it("isDirty is false initially", () => {
|
||||
const form = useLiveForm(testForm)
|
||||
expect(get(form.isDirty)).toBe(false)
|
||||
})
|
||||
|
||||
it("isTouched is false initially", () => {
|
||||
const form = useLiveForm(testForm)
|
||||
expect(get(form.isTouched)).toBe(false)
|
||||
})
|
||||
|
||||
it("submitCount is 0 initially", () => {
|
||||
const form = useLiveForm(testForm)
|
||||
expect(get(form.submitCount)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// field() — value
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("field() — value", () => {
|
||||
it("reflects initial form values", () => {
|
||||
const form = useLiveForm({ ...testForm, values: { name: "Alice", email: "" } })
|
||||
const nameField = form.field("name")
|
||||
expect(get(nameField).value).toBe("Alice")
|
||||
})
|
||||
|
||||
it("reflects empty string initial value", () => {
|
||||
const form = useLiveForm(testForm)
|
||||
const nameField = form.field("name")
|
||||
expect(get(nameField).value).toBe("")
|
||||
})
|
||||
|
||||
it("set() updates the field value in the store", () => {
|
||||
const form = useLiveForm(testForm)
|
||||
const nameField = form.field("name")
|
||||
nameField.set("Bob")
|
||||
expect(get(nameField).value).toBe("Bob")
|
||||
})
|
||||
|
||||
it("update() transforms the field value", () => {
|
||||
const form = useLiveForm({ ...testForm, values: { name: "hello", email: "" } })
|
||||
const nameField = form.field<string>("name")
|
||||
nameField.update((v) => v.toUpperCase())
|
||||
expect(get(nameField).value).toBe("HELLO")
|
||||
})
|
||||
|
||||
it("isDirty becomes true after set()", () => {
|
||||
const form = useLiveForm(testForm)
|
||||
const nameField = form.field("name")
|
||||
expect(get(form.isDirty)).toBe(false)
|
||||
nameField.set("Bob")
|
||||
expect(get(form.isDirty)).toBe(true)
|
||||
})
|
||||
|
||||
it("field isDirty is false initially, true after mutation", () => {
|
||||
const form = useLiveForm(testForm)
|
||||
const nameField = form.field("name")
|
||||
expect(get(nameField).isDirty).toBe(false)
|
||||
nameField.set("changed")
|
||||
expect(get(nameField).isDirty).toBe(true)
|
||||
})
|
||||
|
||||
it("memoizes — same field instance returned for same path", () => {
|
||||
const form = useLiveForm(testForm)
|
||||
const a = form.field("name")
|
||||
const b = form.field("name")
|
||||
expect(a).toBe(b)
|
||||
})
|
||||
|
||||
it("separate instances for same path with different options", () => {
|
||||
const form = useLiveForm(testForm)
|
||||
const a = form.field("agree", { type: "checkbox", value: "yes" })
|
||||
const b = form.field("agree", { type: "checkbox", value: "no" })
|
||||
expect(a).not.toBe(b)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// field() — errors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("field() — errors", () => {
|
||||
it("errors is empty array when no errors", () => {
|
||||
const form = useLiveForm(testForm)
|
||||
expect(get(form.field("name")).errors).toEqual([])
|
||||
})
|
||||
|
||||
it("errors reflects form.errors for a field", () => {
|
||||
const form = useLiveForm({
|
||||
...testForm,
|
||||
errors: { name: ["can't be blank"] },
|
||||
})
|
||||
expect(get(form.field("name")).errors).toEqual(["can't be blank"])
|
||||
})
|
||||
|
||||
it("errors can contain multiple messages", () => {
|
||||
const form = useLiveForm({
|
||||
...testForm,
|
||||
errors: { email: ["is invalid", "can't be blank"] },
|
||||
})
|
||||
expect(get(form.field("email")).errors).toEqual(["is invalid", "can't be blank"])
|
||||
})
|
||||
|
||||
it("errorMessage is undefined when no errors", () => {
|
||||
const form = useLiveForm(testForm)
|
||||
expect(get(form.field("name")).errorMessage).toBeUndefined()
|
||||
})
|
||||
|
||||
it("errorMessage returns first error string", () => {
|
||||
const form = useLiveForm({
|
||||
...testForm,
|
||||
errors: { name: ["can't be blank", "is too short"] },
|
||||
})
|
||||
expect(get(form.field("name")).errorMessage).toBe("can't be blank")
|
||||
})
|
||||
|
||||
it("isValid is false when field has errors", () => {
|
||||
const form = useLiveForm({ ...testForm, errors: { name: ["required"] } })
|
||||
expect(get(form.field("name")).isValid).toBe(false)
|
||||
})
|
||||
|
||||
it("isValid is true when field has no errors", () => {
|
||||
const form = useLiveForm(testForm)
|
||||
expect(get(form.field("name")).isValid).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// field() — attrs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("field() — attrs", () => {
|
||||
it("attrs includes name and id", () => {
|
||||
const form = useLiveForm(testForm)
|
||||
const attrs = get(form.field("name")).attrs
|
||||
expect(attrs.name).toBe("name")
|
||||
expect(attrs.id).toBe("name")
|
||||
})
|
||||
|
||||
it("attrs includes value reflecting current field value", () => {
|
||||
const form = useLiveForm({ ...testForm, values: { name: "Alice", email: "" } })
|
||||
const attrs = get(form.field("name")).attrs
|
||||
expect(attrs.value).toBe("Alice")
|
||||
})
|
||||
|
||||
it("attrs.aria-invalid is false when field is valid", () => {
|
||||
const form = useLiveForm(testForm)
|
||||
expect(get(form.field("name")).attrs["aria-invalid"]).toBe(false)
|
||||
})
|
||||
|
||||
it("attrs.aria-invalid is true when field has errors", () => {
|
||||
const form = useLiveForm({ ...testForm, errors: { name: ["required"] } })
|
||||
expect(get(form.field("name")).attrs["aria-invalid"]).toBe(true)
|
||||
})
|
||||
|
||||
it("attrs.aria-describedby is set when field has errors", () => {
|
||||
const form = useLiveForm({ ...testForm, errors: { name: ["required"] } })
|
||||
expect(get(form.field("name")).attrs["aria-describedby"]).toBe("name-error")
|
||||
})
|
||||
|
||||
it("attrs.aria-describedby is absent when field has no errors", () => {
|
||||
const form = useLiveForm(testForm)
|
||||
expect(get(form.field("name")).attrs["aria-describedby"]).toBeUndefined()
|
||||
})
|
||||
|
||||
it("attrs includes oninput and onblur handlers", () => {
|
||||
const form = useLiveForm(testForm)
|
||||
const attrs = get(form.field("name")).attrs
|
||||
expect(typeof attrs.oninput).toBe("function")
|
||||
expect(typeof attrs.onblur).toBe("function")
|
||||
})
|
||||
|
||||
it("attrs.oninput updates field value via event", () => {
|
||||
const form = useLiveForm(testForm)
|
||||
const nameField = form.field("name")
|
||||
const attrs = get(nameField).attrs
|
||||
const fakeEvent = { target: { value: "Carol" } } as unknown as Event
|
||||
attrs.oninput(fakeEvent)
|
||||
expect(get(nameField).value).toBe("Carol")
|
||||
})
|
||||
|
||||
it("attrs.onblur marks field as touched", () => {
|
||||
const form = useLiveForm(testForm)
|
||||
const nameField = form.field("name")
|
||||
expect(get(nameField).isTouched).toBe(false)
|
||||
get(nameField).attrs.onblur()
|
||||
expect(get(nameField).isTouched).toBe(true)
|
||||
})
|
||||
|
||||
it("sanitizes dotted paths to valid id strings", () => {
|
||||
const form = useLiveForm({
|
||||
name: "user",
|
||||
values: { profile: { bio: "" } } as any,
|
||||
errors: {},
|
||||
valid: true,
|
||||
})
|
||||
const attrs = get(form.field("profile.bio")).attrs
|
||||
expect(attrs.id).toBe("profile_bio")
|
||||
expect(attrs.name).toBe("profile.bio")
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Checkbox attrs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("field() — checkbox attrs", () => {
|
||||
it("single checkbox checked when value matches", () => {
|
||||
const form = useLiveForm({
|
||||
name: "prefs",
|
||||
values: { agree: true } as any,
|
||||
errors: {},
|
||||
valid: true,
|
||||
})
|
||||
const attrs = get(form.field("agree", { type: "checkbox" })).attrs
|
||||
expect(attrs.checked).toBe(true)
|
||||
expect(attrs.type).toBe("checkbox")
|
||||
})
|
||||
|
||||
it("single checkbox not checked when value is false", () => {
|
||||
const form = useLiveForm({
|
||||
name: "prefs",
|
||||
values: { agree: false } as any,
|
||||
errors: {},
|
||||
valid: true,
|
||||
})
|
||||
const attrs = get(form.field("agree", { type: "checkbox" })).attrs
|
||||
expect(attrs.checked).toBe(false)
|
||||
})
|
||||
|
||||
it("multi-checkbox checked when optValue is in array", () => {
|
||||
const form = useLiveForm({
|
||||
name: "prefs",
|
||||
values: { roles: ["admin", "editor"] } as any,
|
||||
errors: {},
|
||||
valid: true,
|
||||
})
|
||||
const adminAttrs = get(form.field("roles", { type: "checkbox", value: "admin" })).attrs
|
||||
const userAttrs = get(form.field("roles", { type: "checkbox", value: "user" })).attrs
|
||||
expect(adminAttrs.checked).toBe(true)
|
||||
expect(userAttrs.checked).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fieldArray()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("fieldArray()", () => {
|
||||
const arrayForm: Form<{ items: { title: string }[] }> = {
|
||||
name: "data",
|
||||
values: { items: [{ title: "first" }, { title: "second" }, { title: "third" }] },
|
||||
errors: {},
|
||||
valid: true,
|
||||
}
|
||||
|
||||
it("fields length matches initial array", () => {
|
||||
const form = useLiveForm(arrayForm)
|
||||
const items = form.fieldArray("items")
|
||||
expect(get(items.fields).length).toBe(3)
|
||||
})
|
||||
|
||||
it("fields array contains FormField instances with correct values", () => {
|
||||
const form = useLiveForm(arrayForm)
|
||||
const items = form.fieldArray("items")
|
||||
const fields = get(items.fields)
|
||||
expect(get(fields[0]).value).toEqual({ title: "first" })
|
||||
expect(get(fields[1]).value).toEqual({ title: "second" })
|
||||
})
|
||||
|
||||
it("add() appends an item", () => {
|
||||
const form = useLiveForm(arrayForm)
|
||||
const items = form.fieldArray("items")
|
||||
items.add({ title: "fourth" })
|
||||
expect(get(items.fields).length).toBe(4)
|
||||
expect(get(get(items.fields)[3]).value).toEqual({ title: "fourth" })
|
||||
})
|
||||
|
||||
it("add() with no args appends an empty object", () => {
|
||||
const form = useLiveForm(arrayForm)
|
||||
const items = form.fieldArray("items")
|
||||
items.add()
|
||||
expect(get(items.fields).length).toBe(4)
|
||||
})
|
||||
|
||||
it("remove() removes item at index", () => {
|
||||
const form = useLiveForm(arrayForm)
|
||||
const items = form.fieldArray("items")
|
||||
items.remove(0)
|
||||
expect(get(items.fields).length).toBe(2)
|
||||
expect(get(get(items.fields)[0]).value).toEqual({ title: "second" })
|
||||
})
|
||||
|
||||
it("move() swaps items", () => {
|
||||
const form = useLiveForm(arrayForm)
|
||||
const items = form.fieldArray("items")
|
||||
items.move(0, 1)
|
||||
const fields = get(items.fields)
|
||||
expect(get(fields[0]).value).toEqual({ title: "second" })
|
||||
expect(get(fields[1]).value).toEqual({ title: "first" })
|
||||
})
|
||||
|
||||
it("move() with out-of-bounds indices is a no-op", () => {
|
||||
const form = useLiveForm(arrayForm)
|
||||
const items = form.fieldArray("items")
|
||||
items.move(0, 99)
|
||||
expect(get(items.fields).length).toBe(3)
|
||||
expect(get(get(items.fields)[0]).value).toEqual({ title: "first" })
|
||||
})
|
||||
|
||||
it("fieldArray is memoized — same instance returned for same path", () => {
|
||||
const form = useLiveForm(arrayForm)
|
||||
expect(form.fieldArray("items")).toBe(form.fieldArray("items"))
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// sync()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("sync()", () => {
|
||||
it("updates currentErrors with new errors", () => {
|
||||
const form = useLiveForm(testForm)
|
||||
const nameField = form.field("name")
|
||||
expect(get(nameField).errors).toEqual([])
|
||||
|
||||
form.sync({
|
||||
...testForm,
|
||||
errors: { name: ["can't be blank"] },
|
||||
valid: false,
|
||||
})
|
||||
|
||||
expect(get(nameField).errors).toEqual(["can't be blank"])
|
||||
})
|
||||
|
||||
it("updates currentValues when not validating", () => {
|
||||
const form = useLiveForm(testForm)
|
||||
const nameField = form.field("name")
|
||||
expect(get(nameField).value).toBe("")
|
||||
|
||||
form.sync({ ...testForm, values: { name: "Server", email: "" } })
|
||||
|
||||
expect(get(nameField).value).toBe("Server")
|
||||
})
|
||||
|
||||
it("updates isValid based on synced errors", () => {
|
||||
const form = useLiveForm(testForm)
|
||||
expect(get(form.isValid)).toBe(true)
|
||||
|
||||
form.sync({ ...testForm, errors: { email: ["is invalid"] }, valid: false })
|
||||
|
||||
expect(get(form.isValid)).toBe(false)
|
||||
})
|
||||
|
||||
it("does not overwrite values while validating (debounce in-flight)", () => {
|
||||
vi.useFakeTimers()
|
||||
const form = useLiveForm(testForm, {
|
||||
changeEvent: "validate",
|
||||
debounceInMiliseconds: 300,
|
||||
})
|
||||
const nameField = form.field("name")
|
||||
|
||||
// User types — debounce timer starts, isValidating = true.
|
||||
nameField.set("typing")
|
||||
|
||||
// Server responds with old data before debounce fires — should NOT overwrite.
|
||||
form.sync({ ...testForm, values: { name: "", email: "" } })
|
||||
expect(get(nameField).value).toBe("typing")
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Debounce and pushEvent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("debounce and pushEvent", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it("does not call pushEvent immediately on set()", () => {
|
||||
const form = useLiveForm(testForm, { changeEvent: "validate" })
|
||||
form.field("name").set("Bob")
|
||||
expect(mockPushEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("calls pushEvent after debounce delay", () => {
|
||||
const form = useLiveForm(testForm, {
|
||||
changeEvent: "validate",
|
||||
debounceInMiliseconds: 300,
|
||||
})
|
||||
form.field("name").set("Bob")
|
||||
vi.advanceTimersByTime(300)
|
||||
expect(mockPushEvent).toHaveBeenCalledWith("validate", { user: { name: "Bob", email: "" } })
|
||||
})
|
||||
|
||||
it("debounces multiple rapid set() calls into one pushEvent", () => {
|
||||
const form = useLiveForm(testForm, {
|
||||
changeEvent: "validate",
|
||||
debounceInMiliseconds: 300,
|
||||
})
|
||||
const nameField = form.field("name")
|
||||
nameField.set("B")
|
||||
nameField.set("Bo")
|
||||
nameField.set("Bob")
|
||||
vi.advanceTimersByTime(300)
|
||||
expect(mockPushEvent).toHaveBeenCalledTimes(1)
|
||||
expect(mockPushEvent).toHaveBeenCalledWith("validate", { user: { name: "Bob", email: "" } })
|
||||
})
|
||||
|
||||
it("does not call pushEvent when changeEvent is null", () => {
|
||||
const form = useLiveForm(testForm, { changeEvent: null })
|
||||
form.field("name").set("Bob")
|
||||
vi.advanceTimersByTime(1000)
|
||||
expect(mockPushEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// submit()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("submit()", () => {
|
||||
it("increments submitCount", async () => {
|
||||
const form = useLiveForm(testForm)
|
||||
expect(get(form.submitCount)).toBe(0)
|
||||
// Mock pushEvent to call the reply callback
|
||||
vi.mocked(mockPushEvent).mockImplementation((_event, _payload, onReply) => {
|
||||
onReply?.({}, 1)
|
||||
return 1
|
||||
})
|
||||
await form.submit()
|
||||
expect(get(form.submitCount)).toBe(1)
|
||||
})
|
||||
|
||||
it("calls pushEvent with submitEvent and current values", async () => {
|
||||
vi.mocked(mockPushEvent).mockImplementation((_event, _payload, onReply) => {
|
||||
onReply?.({}, 1)
|
||||
return 1
|
||||
})
|
||||
const form = useLiveForm(
|
||||
{ ...testForm, values: { name: "Alice", email: "a@b.com" } },
|
||||
{ submitEvent: "submit" }
|
||||
)
|
||||
await form.submit()
|
||||
expect(mockPushEvent).toHaveBeenCalledWith(
|
||||
"submit",
|
||||
{ user: { name: "Alice", email: "a@b.com" } },
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it("resets form when server replies with { reset: true }", async () => {
|
||||
vi.mocked(mockPushEvent).mockImplementation((_event, _payload, onReply) => {
|
||||
onReply?.({ reset: true }, 1)
|
||||
return 1
|
||||
})
|
||||
const form = useLiveForm({ ...testForm, values: { name: "Alice", email: "" } })
|
||||
form.field("name").set("Bob")
|
||||
expect(get(form.isDirty)).toBe(true)
|
||||
|
||||
await form.submit()
|
||||
|
||||
expect(get(form.isDirty)).toBe(false)
|
||||
expect(get(form.submitCount)).toBe(0)
|
||||
})
|
||||
|
||||
it("does not reset when server does not reply with reset", async () => {
|
||||
vi.mocked(mockPushEvent).mockImplementation((_event, _payload, onReply) => {
|
||||
onReply?.({}, 1)
|
||||
return 1
|
||||
})
|
||||
const form = useLiveForm({ ...testForm, values: { name: "Alice", email: "" } })
|
||||
form.field("name").set("Bob")
|
||||
await form.submit()
|
||||
expect(get(form.isDirty)).toBe(true)
|
||||
})
|
||||
|
||||
it("gracefully skips pushEvent when no live context", async () => {
|
||||
vi.mocked(getContext).mockReturnValue(undefined)
|
||||
const form = useLiveForm(testForm)
|
||||
const result = await form.submit()
|
||||
expect(result).toBeUndefined()
|
||||
expect(mockPushEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// reset()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("reset()", () => {
|
||||
it("restores field values to initial values", () => {
|
||||
const form = useLiveForm({ ...testForm, values: { name: "Alice", email: "" } })
|
||||
const nameField = form.field("name")
|
||||
nameField.set("Bob")
|
||||
expect(get(nameField).value).toBe("Bob")
|
||||
|
||||
form.reset()
|
||||
|
||||
expect(get(nameField).value).toBe("Alice")
|
||||
})
|
||||
|
||||
it("clears dirty state", () => {
|
||||
const form = useLiveForm(testForm)
|
||||
form.field("name").set("dirty")
|
||||
expect(get(form.isDirty)).toBe(true)
|
||||
|
||||
form.reset()
|
||||
|
||||
expect(get(form.isDirty)).toBe(false)
|
||||
})
|
||||
|
||||
it("clears errors", () => {
|
||||
const form = useLiveForm({ ...testForm, errors: { name: ["required"] } })
|
||||
form.reset()
|
||||
expect(get(form.isValid)).toBe(true)
|
||||
expect(get(form.field("name")).errors).toEqual([])
|
||||
})
|
||||
|
||||
it("resets submitCount to 0", async () => {
|
||||
vi.mocked(mockPushEvent).mockImplementation((_event, _payload, onReply) => {
|
||||
onReply?.({}, 1)
|
||||
return 1
|
||||
})
|
||||
const form = useLiveForm(testForm)
|
||||
await form.submit()
|
||||
expect(get(form.submitCount)).toBe(1)
|
||||
form.reset()
|
||||
expect(get(form.submitCount)).toBe(0)
|
||||
})
|
||||
|
||||
it("clears touched state", () => {
|
||||
const form = useLiveForm(testForm)
|
||||
const nameField = form.field("name")
|
||||
get(nameField).attrs.onblur()
|
||||
expect(get(form.isTouched)).toBe(true)
|
||||
|
||||
form.reset()
|
||||
|
||||
expect(get(form.isTouched)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// submit() — debounce cancellation (M1 fix)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("submit() — debounce cancellation", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it("cancels pending debounce timer before sending submit", async () => {
|
||||
vi.mocked(mockPushEvent).mockImplementation((_event, _payload, onReply) => {
|
||||
onReply?.({}, 1)
|
||||
return 1
|
||||
})
|
||||
const form = useLiveForm(testForm, { changeEvent: "validate", debounceInMiliseconds: 300 })
|
||||
|
||||
// User types — debounce timer starts.
|
||||
form.field("name").set("Bob")
|
||||
expect(get(form.isValidating)).toBe(true)
|
||||
|
||||
// User submits before debounce fires.
|
||||
await form.submit()
|
||||
|
||||
// Advance past debounce window — validate pushEvent must NOT fire.
|
||||
vi.advanceTimersByTime(300)
|
||||
|
||||
// Only the submit pushEvent should have been called, not validate.
|
||||
expect(mockPushEvent).toHaveBeenCalledTimes(1)
|
||||
expect(mockPushEvent).toHaveBeenCalledWith("submit", expect.any(Object), expect.any(Function))
|
||||
})
|
||||
|
||||
it("clears isValidating flag when submit cancels the debounce", async () => {
|
||||
vi.mocked(mockPushEvent).mockImplementation((_event, _payload, onReply) => {
|
||||
onReply?.({}, 1)
|
||||
return 1
|
||||
})
|
||||
const form = useLiveForm(testForm, { changeEvent: "validate", debounceInMiliseconds: 300 })
|
||||
|
||||
form.field("name").set("Bob")
|
||||
expect(get(form.isValidating)).toBe(true)
|
||||
|
||||
await form.submit()
|
||||
|
||||
expect(get(form.isValidating)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// prepareData option (L2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("prepareData option", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it("transforms values before pushEvent on change", () => {
|
||||
const prepareData = vi.fn((data: Record<string, unknown>) => ({ ...data, _extra: "yes" }))
|
||||
const form = useLiveForm(testForm, {
|
||||
changeEvent: "validate",
|
||||
debounceInMiliseconds: 300,
|
||||
prepareData,
|
||||
})
|
||||
|
||||
form.field("name").set("Bob")
|
||||
vi.advanceTimersByTime(300)
|
||||
|
||||
expect(prepareData).toHaveBeenCalledWith({ name: "Bob", email: "" })
|
||||
expect(mockPushEvent).toHaveBeenCalledWith("validate", {
|
||||
user: { name: "Bob", email: "", _extra: "yes" },
|
||||
})
|
||||
})
|
||||
|
||||
it("transforms values before pushEvent on submit", async () => {
|
||||
const prepareData = vi.fn((data: Record<string, unknown>) => ({ ...data, _token: "tok" }))
|
||||
vi.mocked(mockPushEvent).mockImplementation((_event, _payload, onReply) => {
|
||||
onReply?.({}, 1)
|
||||
return 1
|
||||
})
|
||||
const form = useLiveForm(
|
||||
{ ...testForm, values: { name: "Alice", email: "a@b.com" } },
|
||||
{ prepareData }
|
||||
)
|
||||
|
||||
await form.submit()
|
||||
|
||||
expect(mockPushEvent).toHaveBeenCalledWith(
|
||||
"submit",
|
||||
{ user: { name: "Alice", email: "a@b.com", _token: "tok" } },
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FormField sub-field and sub-fieldArray methods (L3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("FormField.field() and FormField.fieldArray() sub-access", () => {
|
||||
const nestedForm = {
|
||||
name: "data",
|
||||
values: { profile: { bio: "hello" }, items: [{ title: "one" }] },
|
||||
errors: {} as Record<string, unknown>,
|
||||
valid: true,
|
||||
}
|
||||
|
||||
it("field().field() accesses a nested sub-path", () => {
|
||||
const form = useLiveForm(nestedForm as any)
|
||||
const profileField = form.field("profile")
|
||||
const bioField = profileField.field("bio")
|
||||
expect(get(bioField).value).toBe("hello")
|
||||
})
|
||||
|
||||
it("field().field() sub-field path is equivalent to full dot-path", () => {
|
||||
const form = useLiveForm(nestedForm as any)
|
||||
const viaSubField = form.field("profile").field("bio")
|
||||
const viaFullPath = form.field("profile.bio")
|
||||
// Both should refer to the same memoized instance.
|
||||
expect(viaSubField).toBe(viaFullPath)
|
||||
})
|
||||
|
||||
it("field().field() set() updates the nested value", () => {
|
||||
const form = useLiveForm(nestedForm as any)
|
||||
const bioField = form.field("profile").field<string>("bio")
|
||||
bioField.set("updated")
|
||||
expect(get(bioField).value).toBe("updated")
|
||||
})
|
||||
|
||||
it("field().fieldArray() accesses a nested array sub-path", () => {
|
||||
const form = useLiveForm(nestedForm as any)
|
||||
const itemsArray = form.field("items").fieldArray("") // via field sub-access
|
||||
// Direct fieldArray should be equivalent
|
||||
const directItems = form.fieldArray("items")
|
||||
expect(get(directItems.fields).length).toBe(1)
|
||||
})
|
||||
})
|
||||
521
assets/js/live_svelte/useLiveForm.ts
Normal file
521
assets/js/live_svelte/useLiveForm.ts
Normal file
|
|
@ -0,0 +1,521 @@
|
|||
/**
|
||||
* useLiveForm — Svelte composable for Phoenix LiveView form binding.
|
||||
*
|
||||
* Binds to an Ecto changeset-backed form prop, providing reactive field
|
||||
* instances with value, errors, and attrs for inputs. Uses Svelte stores
|
||||
* (`writable`/`derived` from `svelte/store`) for reactivity so it compiles
|
||||
* in plain `.ts` without the Svelte compiler plugin in Vitest.
|
||||
*
|
||||
* Usage:
|
||||
* ```svelte
|
||||
* <script lang="ts">
|
||||
* import { useLiveForm } from "live_svelte"
|
||||
* let { form: serverForm } = $props()
|
||||
* const liveForm = useLiveForm(serverForm, { changeEvent: "validate", submitEvent: "submit" })
|
||||
* $effect(() => { liveForm.sync(serverForm) })
|
||||
* const nameField = liveForm.field("name")
|
||||
* </script>
|
||||
* <input {...$nameField.attrs} />
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { writable, derived, get, type Readable, type Writable } from "svelte/store"
|
||||
import { useLiveSvelte } from "./composables"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utility functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function deepClone<T>(val: T): T {
|
||||
return JSON.parse(JSON.stringify(val))
|
||||
}
|
||||
|
||||
function parsePath(path: string): string[] {
|
||||
return path.replace(/\[(\d+)\]/g, ".$1").split(".").filter(Boolean)
|
||||
}
|
||||
|
||||
function getByPath(obj: any, keys: string[]): any {
|
||||
return keys.reduce((acc: any, key: string) => (acc != null ? acc[key] : undefined), obj)
|
||||
}
|
||||
|
||||
/** In-place mutation on an already-cloned object. */
|
||||
function setByPath(obj: any, keys: string[], value: any): void {
|
||||
if (keys.length === 0) return
|
||||
const last = keys[keys.length - 1]
|
||||
const parent = keys.slice(0, -1).reduce((acc: any, k: string) => acc?.[k], obj)
|
||||
if (parent !== undefined) parent[last] = value
|
||||
}
|
||||
|
||||
function sanitizeId(path: string): string {
|
||||
return path.replace(/[\[\].]/g, "_").replace(/_+/g, "_").replace(/^_|_$/g, "")
|
||||
}
|
||||
|
||||
function hasAnyErrors(errors: any): boolean {
|
||||
if (errors == null) return false
|
||||
if (Array.isArray(errors)) {
|
||||
if (errors.length === 0) return false
|
||||
if (typeof errors[0] === "string") return true
|
||||
return errors.some((e: any) => hasAnyErrors(e))
|
||||
}
|
||||
if (typeof errors === "object") {
|
||||
return Object.values(errors).some((v: any) => hasAnyErrors(v))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Recursive type for Ecto changeset error maps.
|
||||
* Each key maps to either an array of error strings or a nested error map.
|
||||
*/
|
||||
export type FormErrors<T extends object> = {
|
||||
[K in keyof T]?: string[] | Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape of the Phoenix.HTML.Form encoded by LiveSvelte.Encoder.
|
||||
* Produced by `to_form(changeset)` in the LiveView and passed as a prop.
|
||||
*/
|
||||
export interface Form<T extends object> {
|
||||
/** Form name used as the key in `pushEvent` payloads: `{ [name]: values }` */
|
||||
name: string
|
||||
/** Current field values */
|
||||
values: T
|
||||
/** Validation errors per field */
|
||||
errors: FormErrors<T>
|
||||
/** Whether the changeset is valid */
|
||||
valid: boolean
|
||||
}
|
||||
|
||||
/** Options for `field()` to specify input type and checkbox value. */
|
||||
export interface FieldOptions {
|
||||
/** HTML input type, e.g. `"checkbox"`, `"radio"`, `"number"`. */
|
||||
type?: string
|
||||
/** For checkboxes: the value this input represents when checked. */
|
||||
value?: unknown
|
||||
}
|
||||
|
||||
/** Options for `useLiveForm`. */
|
||||
export interface FormOptions {
|
||||
/** Server event for validation. `null` disables automatic validation events. */
|
||||
changeEvent?: string | null
|
||||
/** Server event for form submission. Default: `"submit"`. */
|
||||
submitEvent?: string
|
||||
/** Debounce delay in ms for change events. Default: `300`. */
|
||||
debounceInMiliseconds?: number
|
||||
/** Transform form data before sending to server. */
|
||||
prepareData?: (data: Record<string, unknown>) => Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Attributes to spread onto an `<input>` element. */
|
||||
export interface FieldAttrs {
|
||||
name: string
|
||||
id: string
|
||||
oninput: (e: Event) => void
|
||||
onblur: () => void
|
||||
"aria-invalid": boolean
|
||||
"aria-describedby"?: string
|
||||
value?: unknown
|
||||
checked?: boolean
|
||||
type?: string
|
||||
}
|
||||
|
||||
/** Reactive state snapshot for a single form field (the store's value). */
|
||||
export interface FieldState<V> {
|
||||
value: V
|
||||
errors: string[]
|
||||
errorMessage: string | undefined
|
||||
isValid: boolean
|
||||
isDirty: boolean
|
||||
isTouched: boolean
|
||||
attrs: FieldAttrs
|
||||
}
|
||||
|
||||
/**
|
||||
* A reactive field store. Subscribe via `$nameField` in Svelte templates.
|
||||
* Call `nameField.set(value)` to update the field value programmatically.
|
||||
*/
|
||||
export interface FormField<V> extends Readable<FieldState<V>> {
|
||||
set(value: V): void
|
||||
update(updater: (currentValue: V) => V): void
|
||||
/** Access a nested sub-field by dot-path relative to this field. */
|
||||
field(subPath: string, options?: FieldOptions): FormField<any>
|
||||
/** Access a nested array sub-field by dot-path relative to this field. */
|
||||
fieldArray(subPath: string): FormFieldArray<any>
|
||||
}
|
||||
|
||||
/**
|
||||
* A reactive array-field store. Provides `fields` (array of item field stores)
|
||||
* and `add`/`remove`/`move` operations.
|
||||
*/
|
||||
export interface FormFieldArray<V> extends Readable<FieldState<V[]>> {
|
||||
set(value: V[]): void
|
||||
/** Reactive store of per-item `FormField` instances. Subscribe via `$itemFields`. */
|
||||
fields: Readable<FormField<V>[]>
|
||||
add(item?: Partial<V>): void
|
||||
remove(index: number): void
|
||||
move(from: number, to: number): void
|
||||
}
|
||||
|
||||
/** Return value of `useLiveForm`. */
|
||||
export interface UseLiveFormReturn<T extends object> {
|
||||
isValid: Readable<boolean>
|
||||
isDirty: Readable<boolean>
|
||||
isTouched: Readable<boolean>
|
||||
isValidating: Readable<boolean>
|
||||
submitCount: Readable<number>
|
||||
/** Frozen snapshot of initial values. */
|
||||
initialValues: Readonly<T>
|
||||
field<V = unknown>(path: string, options?: FieldOptions): FormField<V>
|
||||
fieldArray<V = unknown>(path: string): FormFieldArray<V>
|
||||
/** Send submit event to the LiveView. Handles `{ reset: true }` reply. */
|
||||
submit(): Promise<any>
|
||||
/** Reset form to initial values and clear touched/dirty state. */
|
||||
reset(): void
|
||||
/**
|
||||
* Merge server-side form updates into the composable.
|
||||
* Always updates errors; updates values only when not validating.
|
||||
* Call this from a Svelte `$effect(() => { liveForm.sync(serverForm) })`.
|
||||
*/
|
||||
sync(newForm: Form<T>): void
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useLiveForm<T extends object>(
|
||||
form: Form<T>,
|
||||
options: FormOptions = {}
|
||||
): UseLiveFormReturn<T> {
|
||||
const {
|
||||
changeEvent = null,
|
||||
submitEvent = "submit",
|
||||
debounceInMiliseconds = 300,
|
||||
prepareData = (d: Record<string, unknown>) => d,
|
||||
} = options
|
||||
|
||||
// Graceful degradation: composable works without LiveSvelte context (SSR, tests without mock).
|
||||
let liveCtx: ReturnType<typeof useLiveSvelte> | null = null
|
||||
try {
|
||||
liveCtx = useLiveSvelte()
|
||||
} catch {
|
||||
// SSR or test without LiveSvelte context — pushEvent unavailable.
|
||||
}
|
||||
|
||||
// Snapshot initial values for dirty detection and reset.
|
||||
const initialSnapshot = deepClone(form.values)
|
||||
const initialValues = Object.freeze(initialSnapshot) as Readonly<T>
|
||||
|
||||
// Core reactive stores.
|
||||
const currentValues: Writable<T> = writable(deepClone(form.values))
|
||||
const currentErrors: Writable<FormErrors<T>> = writable(deepClone(form.errors))
|
||||
const touchedPaths: Writable<Set<string>> = writable(new Set())
|
||||
const submitCountStore: Writable<number> = writable(0)
|
||||
const isValidatingStore: Writable<boolean> = writable(false)
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// Form-level derived stores.
|
||||
const isValid: Readable<boolean> = derived(
|
||||
currentErrors,
|
||||
($errors) => !hasAnyErrors($errors)
|
||||
)
|
||||
|
||||
const isDirty: Readable<boolean> = derived(
|
||||
currentValues,
|
||||
($values) => JSON.stringify($values) !== JSON.stringify(initialSnapshot)
|
||||
)
|
||||
|
||||
const isTouched: Readable<boolean> = derived(
|
||||
[submitCountStore, touchedPaths],
|
||||
([$count, $touched]) => $count > 0 || $touched.size > 0
|
||||
)
|
||||
|
||||
function sendChange(): void {
|
||||
if (!changeEvent || !liveCtx) return
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
isValidatingStore.set(true)
|
||||
debounceTimer = setTimeout(() => {
|
||||
const values = get(currentValues)
|
||||
const data = prepareData(values as Record<string, unknown>)
|
||||
liveCtx!.pushEvent(changeEvent, { [form.name]: data })
|
||||
isValidatingStore.set(false)
|
||||
debounceTimer = null
|
||||
}, debounceInMiliseconds)
|
||||
}
|
||||
|
||||
// Memoize field instances per (path, opts) to prevent recreation on re-renders.
|
||||
const fieldCache = new Map<string, FormField<any>>()
|
||||
const fieldArrayCache = new Map<string, FormFieldArray<any>>()
|
||||
|
||||
function createField<V>(path: string, opts: FieldOptions = {}): FormField<V> {
|
||||
const cacheKey = `${path}::${JSON.stringify(opts)}`
|
||||
if (fieldCache.has(cacheKey)) return fieldCache.get(cacheKey) as FormField<V>
|
||||
|
||||
const keys = parsePath(path)
|
||||
const optValue = opts.value
|
||||
const fieldId =
|
||||
sanitizeId(path) + (optValue !== undefined ? `_${sanitizeId(String(optValue))}` : "")
|
||||
|
||||
// Stable event handlers closed over constants (not re-created per derive).
|
||||
const onblurHandler = () => {
|
||||
touchedPaths.update((s) => {
|
||||
s.add(path)
|
||||
return s
|
||||
})
|
||||
}
|
||||
|
||||
let oninputHandler: (e: Event) => void
|
||||
|
||||
if (opts.type === "checkbox") {
|
||||
oninputHandler = (e: Event) => {
|
||||
const target = e.target as HTMLInputElement
|
||||
currentValues.update((vals) => {
|
||||
const clone = deepClone(vals)
|
||||
const current = getByPath(clone, keys)
|
||||
if (Array.isArray(current)) {
|
||||
// Multi-checkbox: toggle value in array.
|
||||
const arr = [...current]
|
||||
const idx = arr.indexOf(optValue)
|
||||
if (target.checked && idx === -1) arr.push(optValue)
|
||||
else if (!target.checked && idx !== -1) arr.splice(idx, 1)
|
||||
setByPath(clone, keys, arr)
|
||||
} else {
|
||||
// Single checkbox: set value or null.
|
||||
const checkedValue = optValue !== undefined ? optValue : true
|
||||
setByPath(clone, keys, target.checked ? checkedValue : null)
|
||||
}
|
||||
return clone
|
||||
})
|
||||
sendChange()
|
||||
}
|
||||
} else {
|
||||
oninputHandler = (e: Event) => {
|
||||
const target = e.target as HTMLInputElement
|
||||
currentValues.update((vals) => {
|
||||
const clone = deepClone(vals)
|
||||
setByPath(clone, keys, target.value)
|
||||
return clone
|
||||
})
|
||||
sendChange()
|
||||
}
|
||||
}
|
||||
|
||||
const stateStore: Readable<FieldState<V>> = derived(
|
||||
[currentValues, currentErrors, touchedPaths, submitCountStore],
|
||||
([$values, $errors, $touched, $count]) => {
|
||||
const value = getByPath($values, keys) as V
|
||||
const rawErrors = getByPath($errors, keys)
|
||||
// Errors are a string[] if the array's first element is a string (or empty).
|
||||
const errors: string[] =
|
||||
Array.isArray(rawErrors) &&
|
||||
(rawErrors.length === 0 || typeof rawErrors[0] === "string")
|
||||
? (rawErrors as string[])
|
||||
: []
|
||||
const errorMessage = errors.length > 0 ? errors[0] : undefined
|
||||
const isFieldValid = errors.length === 0
|
||||
const isTouchedField = $count > 0 || $touched.has(path)
|
||||
const initialVal = getByPath(initialSnapshot, keys)
|
||||
const isFieldDirty = JSON.stringify(value) !== JSON.stringify(initialVal)
|
||||
|
||||
let attrs: FieldAttrs
|
||||
if (opts.type === "checkbox") {
|
||||
const isMulti = Array.isArray(value)
|
||||
attrs = {
|
||||
name: path,
|
||||
id: fieldId,
|
||||
type: "checkbox",
|
||||
...(optValue !== undefined ? { value: optValue } : {}),
|
||||
checked: isMulti
|
||||
? ((value as unknown as any[]) ?? []).includes(optValue)
|
||||
: value === (optValue !== undefined ? optValue : true),
|
||||
oninput: oninputHandler,
|
||||
onblur: onblurHandler,
|
||||
"aria-invalid": !isFieldValid,
|
||||
...(errors.length > 0 ? { "aria-describedby": `${fieldId}-error` } : {}),
|
||||
}
|
||||
} else {
|
||||
attrs = {
|
||||
name: path,
|
||||
id: fieldId,
|
||||
...(opts.type ? { type: opts.type } : {}),
|
||||
value: value as unknown,
|
||||
oninput: oninputHandler,
|
||||
onblur: onblurHandler,
|
||||
"aria-invalid": !isFieldValid,
|
||||
...(errors.length > 0 ? { "aria-describedby": `${fieldId}-error` } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
value,
|
||||
errors,
|
||||
errorMessage,
|
||||
isValid: isFieldValid,
|
||||
isDirty: isFieldDirty,
|
||||
isTouched: isTouchedField,
|
||||
attrs,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const fieldStore: FormField<V> = {
|
||||
subscribe: stateStore.subscribe,
|
||||
|
||||
set(value: V) {
|
||||
currentValues.update((vals) => {
|
||||
const clone = deepClone(vals)
|
||||
setByPath(clone, keys, value)
|
||||
return clone
|
||||
})
|
||||
sendChange()
|
||||
},
|
||||
|
||||
update(updater: (v: V) => V) {
|
||||
currentValues.update((vals) => {
|
||||
const current = getByPath(vals, keys) as V
|
||||
const clone = deepClone(vals)
|
||||
setByPath(clone, keys, updater(current))
|
||||
return clone
|
||||
})
|
||||
sendChange()
|
||||
},
|
||||
|
||||
field(subPath: string, subOpts?: FieldOptions): FormField<any> {
|
||||
return createField(`${path}.${subPath}`, subOpts)
|
||||
},
|
||||
|
||||
fieldArray(subPath: string): FormFieldArray<any> {
|
||||
return createFieldArray(`${path}.${subPath}`)
|
||||
},
|
||||
}
|
||||
|
||||
fieldCache.set(cacheKey, fieldStore)
|
||||
return fieldStore
|
||||
}
|
||||
|
||||
function createFieldArray<V>(path: string): FormFieldArray<V> {
|
||||
if (fieldArrayCache.has(path)) return fieldArrayCache.get(path) as FormFieldArray<V>
|
||||
|
||||
const keys = parsePath(path)
|
||||
const baseField = createField<V[]>(path) as FormField<V[]>
|
||||
|
||||
const fieldsStore: Readable<FormField<V>[]> = derived(currentValues, ($values) => {
|
||||
const arr: V[] = getByPath($values, keys) ?? []
|
||||
return arr.map((_: V, i: number) => createField<V>(`${path}[${i}]`))
|
||||
})
|
||||
|
||||
const arrayStore: FormFieldArray<V> = {
|
||||
subscribe: baseField.subscribe,
|
||||
|
||||
set(value: V[]) {
|
||||
baseField.set(value)
|
||||
},
|
||||
|
||||
fields: fieldsStore,
|
||||
|
||||
add(item?: Partial<V>) {
|
||||
currentValues.update((vals) => {
|
||||
const clone = deepClone(vals)
|
||||
const arr: V[] = getByPath(clone, keys) ?? []
|
||||
setByPath(clone, keys, [...arr, (item ?? {}) as V])
|
||||
return clone
|
||||
})
|
||||
sendChange()
|
||||
},
|
||||
|
||||
remove(index: number) {
|
||||
currentValues.update((vals) => {
|
||||
const clone = deepClone(vals)
|
||||
const arr: V[] = [...(getByPath(clone, keys) ?? [])]
|
||||
arr.splice(index, 1)
|
||||
setByPath(clone, keys, arr)
|
||||
return clone
|
||||
})
|
||||
sendChange()
|
||||
},
|
||||
|
||||
move(from: number, to: number) {
|
||||
currentValues.update((vals) => {
|
||||
const clone = deepClone(vals)
|
||||
const arr: V[] = [...(getByPath(clone, keys) ?? [])]
|
||||
if (from >= 0 && from < arr.length && to >= 0 && to < arr.length) {
|
||||
const [item] = arr.splice(from, 1)
|
||||
arr.splice(to, 0, item)
|
||||
setByPath(clone, keys, arr)
|
||||
}
|
||||
return clone
|
||||
})
|
||||
sendChange()
|
||||
},
|
||||
}
|
||||
|
||||
fieldArrayCache.set(path, arrayStore)
|
||||
return arrayStore
|
||||
}
|
||||
|
||||
async function submit(): Promise<any> {
|
||||
// Cancel any pending debounce to prevent stale validate events after submit.
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = null
|
||||
isValidatingStore.set(false)
|
||||
}
|
||||
|
||||
if (!liveCtx) {
|
||||
console.warn("LiveView hook not available, form submission skipped")
|
||||
return Promise.resolve(undefined)
|
||||
}
|
||||
|
||||
submitCountStore.update((n) => n + 1)
|
||||
|
||||
const values = get(currentValues)
|
||||
const data = prepareData(values as Record<string, unknown>)
|
||||
|
||||
return new Promise((resolve) => {
|
||||
liveCtx!.pushEvent(submitEvent, { [form.name]: data }, (result: any) => {
|
||||
if (result && result.reset) {
|
||||
reset()
|
||||
}
|
||||
resolve(result)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = null
|
||||
}
|
||||
currentValues.set(deepClone(initialSnapshot))
|
||||
currentErrors.set({} as FormErrors<T>)
|
||||
touchedPaths.set(new Set())
|
||||
submitCountStore.set(0)
|
||||
isValidatingStore.set(false)
|
||||
}
|
||||
|
||||
function sync(newForm: Form<T>): void {
|
||||
currentErrors.set(deepClone(newForm.errors))
|
||||
if (!get(isValidatingStore)) {
|
||||
currentValues.set(deepClone(newForm.values))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid,
|
||||
isDirty,
|
||||
isTouched,
|
||||
isValidating: { subscribe: isValidatingStore.subscribe },
|
||||
submitCount: { subscribe: submitCountStore.subscribe },
|
||||
initialValues,
|
||||
field: createField,
|
||||
fieldArray: createFieldArray,
|
||||
submit,
|
||||
reset,
|
||||
sync,
|
||||
}
|
||||
}
|
||||
596
assets/package-lock.json
generated
596
assets/package-lock.json
generated
|
|
@ -13,6 +13,7 @@
|
|||
"@vitest/coverage-v8": "^2.1.0",
|
||||
"esbuild": "^0.24.0",
|
||||
"esbuild-svelte": "^0.9.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
|
|
@ -28,6 +29,13 @@
|
|||
"version": "0.18.15",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@acemir/cssom": {
|
||||
"version": "0.9.31",
|
||||
"resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz",
|
||||
"integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@ampproject/remapping": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
|
||||
|
|
@ -42,6 +50,64 @@
|
|||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz",
|
||||
"integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/css-calc": "^3.1.1",
|
||||
"@csstools/css-color-parser": "^4.0.2",
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0",
|
||||
"lru-cache": "^11.2.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
|
||||
"version": "11.2.6",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
|
||||
"integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/dom-selector": {
|
||||
"version": "6.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz",
|
||||
"integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/nwsapi": "^2.3.9",
|
||||
"bidi-js": "^1.0.3",
|
||||
"css-tree": "^3.1.0",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"lru-cache": "^11.2.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
|
||||
"version": "11.2.6",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
|
||||
"integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/nwsapi": {
|
||||
"version": "2.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
|
||||
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
|
|
@ -99,6 +165,153 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@bramus/specificity": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
|
||||
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-tree": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"specificity": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
|
||||
"integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-calc": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz",
|
||||
"integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-color-parser": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz",
|
||||
"integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/color-helpers": "^6.0.2",
|
||||
"@csstools/css-calc": "^3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-parser-algorithms": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
|
||||
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||
"version": "1.0.28",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.28.tgz",
|
||||
"integrity": "sha512-1NRf1CUBjnr3K7hu8BLxjQrKCxEe8FP/xmPTenAxCRZWVLbmGotkFvG9mfNpjA6k7Bw1bw4BilZq9cu19RA5pg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0"
|
||||
},
|
||||
"node_modules/@csstools/css-tokenizer": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
|
||||
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.24.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.0.tgz",
|
||||
|
|
@ -507,6 +720,24 @@
|
|||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@exodus/bytes": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.14.1.tgz",
|
||||
"integrity": "sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@noble/hashes": "^1.8.0 || ^2.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@noble/hashes": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
|
|
@ -1126,6 +1357,16 @@
|
|||
"acorn": ">=8.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
|
|
@ -1192,6 +1433,16 @@
|
|||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/bidi-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"require-from-string": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
|
||||
|
|
@ -1277,6 +1528,60 @@
|
|||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/css-tree": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz",
|
||||
"integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mdn-data": "2.12.2",
|
||||
"source-map-js": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cssstyle": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.1.0.tgz",
|
||||
"integrity": "sha512-Ml4fP2UT2K3CUBQnVlbdV/8aFDdlY69E+YnwJM+3VUWl08S3J8c8aRuJqCkD9Py8DHZ7zNNvsfKl8psocHZEFg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/css-color": "^5.0.0",
|
||||
"@csstools/css-syntax-patches-for-csstree": "^1.0.28",
|
||||
"css-tree": "^3.1.0",
|
||||
"lru-cache": "^11.2.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/cssstyle/node_modules/lru-cache": {
|
||||
"version": "11.2.6",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
|
||||
"integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"whatwg-url": "^16.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
|
|
@ -1295,6 +1600,13 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deep-eql": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
|
||||
|
|
@ -1319,6 +1631,19 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
||||
|
|
@ -1486,6 +1811,19 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
|
||||
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-escaper": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||
|
|
@ -1493,6 +1831,34 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/http-proxy-agent": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
||||
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.0",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
|
|
@ -1503,6 +1869,13 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-potential-custom-element-name": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-reference": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.2.tgz",
|
||||
|
|
@ -1590,6 +1963,48 @@
|
|||
"@pkgjs/parseargs": "^0.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "28.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz",
|
||||
"integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@acemir/cssom": "^0.9.31",
|
||||
"@asamuzakjp/dom-selector": "^6.8.1",
|
||||
"@bramus/specificity": "^2.4.2",
|
||||
"@exodus/bytes": "^1.11.0",
|
||||
"cssstyle": "^6.0.1",
|
||||
"data-urls": "^7.0.0",
|
||||
"decimal.js": "^10.6.0",
|
||||
"html-encoding-sniffer": "^6.0.0",
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"parse5": "^8.0.0",
|
||||
"saxes": "^6.0.0",
|
||||
"symbol-tree": "^3.2.4",
|
||||
"tough-cookie": "^6.0.0",
|
||||
"undici": "^7.21.0",
|
||||
"w3c-xmlserializer": "^5.0.0",
|
||||
"webidl-conversions": "^8.0.1",
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"whatwg-url": "^16.0.0",
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"canvas": "^3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"canvas": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/locate-character": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
|
||||
|
|
@ -1649,6 +2064,13 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/mdn-data": {
|
||||
"version": "2.12.2",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz",
|
||||
"integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==",
|
||||
"dev": true,
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "9.0.6",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.6.tgz",
|
||||
|
|
@ -1708,6 +2130,19 @@
|
|||
"dev": true,
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
|
||||
"integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
|
|
@ -1800,6 +2235,26 @@
|
|||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||
|
|
@ -1845,6 +2300,19 @@
|
|||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"xmlchars": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=v12.22.7"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
|
|
@ -2068,6 +2536,13 @@
|
|||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/test-exclude": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz",
|
||||
|
|
@ -2127,6 +2602,52 @@
|
|||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts": {
|
||||
"version": "7.0.23",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz",
|
||||
"integrity": "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tldts-core": "^7.0.23"
|
||||
},
|
||||
"bin": {
|
||||
"tldts": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts-core": {
|
||||
"version": "7.0.23",
|
||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.23.tgz",
|
||||
"integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz",
|
||||
"integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tldts": "^7.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
|
||||
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
|
|
@ -2141,6 +2662,16 @@
|
|||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.22.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz",
|
||||
"integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.4.21",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||
|
|
@ -2722,6 +3253,54 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
||||
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
|
||||
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
|
||||
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.11.0",
|
||||
"tr46": "^6.0.0",
|
||||
"webidl-conversions": "^8.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
|
@ -2853,6 +3432,23 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlchars": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/zimmerframe": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz",
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
{
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "^2.1.0",
|
||||
"esbuild": "^0.24.0",
|
||||
"esbuild-svelte": "^0.9.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^2.1.0",
|
||||
"@vitest/coverage-v8": "^2.1.0"
|
||||
"vitest": "^2.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"phoenix": "file:../deps/phoenix",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { defineConfig } from 'vitest/config'
|
|||
// No resolve alias needed unless a test imports them; then add resolve.alias in Vite config.
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
include: ['**/*.test.js', '**/*.spec.js', '**/*.test.ts', '**/*.spec.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
|
|
|
|||
|
|
@ -2,9 +2,14 @@
|
|||
import {preventDefault} from "svelte/legacy"
|
||||
import {fly} from "svelte/transition"
|
||||
import {elasticOut} from "svelte/easing"
|
||||
import {useLiveSvelte, useLiveConnection} from "live_svelte"
|
||||
|
||||
/** @type {{ messages: any, name: any, live: any }} */
|
||||
let {messages, name, live} = $props()
|
||||
/** @type {{ messages: any, name: any }} */
|
||||
let {messages, name} = $props()
|
||||
|
||||
const conn = useLiveConnection()
|
||||
|
||||
const { pushEvent } = useLiveSvelte()
|
||||
|
||||
let body = $state("")
|
||||
let messagesElement = $state()
|
||||
|
|
@ -17,7 +22,7 @@
|
|||
|
||||
function submitMessage() {
|
||||
if (body === "") return
|
||||
live.pushEvent("send_message", {body})
|
||||
pushEvent("send_message", {body})
|
||||
body = ""
|
||||
}
|
||||
</script>
|
||||
|
|
@ -32,6 +37,11 @@
|
|||
{name}
|
||||
</span>
|
||||
</div>
|
||||
{#if !conn.connected}
|
||||
<div data-testid="chat-reconnecting" class="px-4 py-1 text-xs text-warning bg-warning/10 text-center">
|
||||
Reconnecting…
|
||||
</div>
|
||||
{/if}
|
||||
<ul data-testid="chat-messages" bind:this={messagesElement} class="flex flex-col gap-3 flex-1 min-h-0 overflow-x-clip overflow-y-auto px-4 py-2">
|
||||
{#each messages as message (message.id)}
|
||||
{@const me = message.name === name}
|
||||
|
|
|
|||
81
example_project/assets/svelte/FormDemo.svelte
Normal file
81
example_project/assets/svelte/FormDemo.svelte
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
<script lang="ts">
|
||||
import {useLiveForm} from "live_svelte"
|
||||
import type {Form} from "live_svelte"
|
||||
|
||||
interface Props {
|
||||
form: Form<{name: string; email: string}>
|
||||
}
|
||||
|
||||
let {form: serverForm}: Props = $props()
|
||||
|
||||
const liveForm = useLiveForm(serverForm, {
|
||||
changeEvent: "validate",
|
||||
submitEvent: "submit",
|
||||
debounceInMiliseconds: 300,
|
||||
})
|
||||
|
||||
// Sync server-side updates (new errors / values) into the composable.
|
||||
$effect(() => {
|
||||
liveForm.sync(serverForm)
|
||||
})
|
||||
|
||||
// Field stores — memoized, same instance returned on every call.
|
||||
const nameField = liveForm.field("name")
|
||||
const emailField = liveForm.field("email")
|
||||
|
||||
// Form-level stores for template auto-subscription.
|
||||
const isValid = liveForm.isValid
|
||||
</script>
|
||||
|
||||
<div class="w-full min-w-sm mx-auto">
|
||||
<form
|
||||
onsubmit={e => {
|
||||
e.preventDefault()
|
||||
liveForm.submit()
|
||||
}}
|
||||
class="card bg-base-100 shadow-md border border-base-300/50 overflow-hidden"
|
||||
>
|
||||
<div class="card-body gap-4 p-5">
|
||||
<span class="badge badge-ghost badge-sm font-medium text-base-content/70 w-fit"> Contact form </span>
|
||||
|
||||
<!-- Name field -->
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="text-xs font-medium text-base-content/50">Name *</span>
|
||||
<input
|
||||
data-testid="form-name-input"
|
||||
class="input input-bordered input-sm w-full bg-base-200/50 border-base-300"
|
||||
{...$nameField.attrs}
|
||||
/>
|
||||
{#if $nameField.errorMessage}
|
||||
<p data-testid="form-name-error" class="text-error text-xs">
|
||||
{$nameField.errorMessage}
|
||||
</p>
|
||||
{/if}
|
||||
</label>
|
||||
|
||||
<!-- Email field -->
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="text-xs font-medium text-base-content/50">Email *</span>
|
||||
<input
|
||||
data-testid="form-email-input"
|
||||
class="input input-bordered input-sm w-full bg-base-200/50 border-base-300"
|
||||
{...$emailField.attrs}
|
||||
/>
|
||||
{#if $emailField.errorMessage}
|
||||
<p data-testid="form-email-error" class="text-error text-xs">
|
||||
{$emailField.errorMessage}
|
||||
</p>
|
||||
{/if}
|
||||
</label>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<button data-testid="form-submit-btn" type="submit" class="btn btn-sm bg-brand text-white border-0 hover:opacity-90 w-fit">
|
||||
Submit
|
||||
</button>
|
||||
<span data-testid="form-valid-indicator" class="badge badge-ghost border badge-sm font-medium">
|
||||
{$isValid ? "valid" : "invalid"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -34,6 +34,10 @@
|
|||
function updateItem(id) {
|
||||
live.pushEvent("update_item", { id })
|
||||
}
|
||||
|
||||
function addCappedItem() {
|
||||
live.pushEvent("add_capped_item", {})
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="card bg-base-100 shadow-lg border border-base-300/50">
|
||||
|
|
@ -77,6 +81,9 @@
|
|||
<button class="btn btn-sm btn-outline" data-testid="reset-button-at-0" onclick={resetStreamAt0}>
|
||||
Reset (at: 0)
|
||||
</button>
|
||||
<button class="btn btn-sm btn-secondary btn-outline" data-testid="add-capped-button" onclick={addCappedItem}>
|
||||
Add Capped (max 3)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Item count -->
|
||||
|
|
|
|||
|
|
@ -55,6 +55,12 @@ defmodule ExampleWeb.Layouts do
|
|||
links: [
|
||||
%{label: "Notes (OTP)", to: ~p"/live-notes-otp"}
|
||||
]
|
||||
},
|
||||
%{
|
||||
label: "Composables",
|
||||
links: [
|
||||
%{label: "Form (useLiveForm)", to: ~p"/live-form"}
|
||||
]
|
||||
}
|
||||
]
|
||||
end
|
||||
|
|
|
|||
|
|
@ -219,6 +219,24 @@
|
|||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Composables --%>
|
||||
<div class="card bg-base-100 border border-base-300">
|
||||
<div class="card-body">
|
||||
<h2 class="text-lg font-semibold text-base-content mb-4 flex items-center gap-2">
|
||||
<span class="badge badge-neutral badge-lg">7</span>
|
||||
Composables
|
||||
</h2>
|
||||
<ul class="space-y-2">
|
||||
<li>
|
||||
<a href={~p"/live-form"} class="link link-primary">
|
||||
Form (useLiveForm)
|
||||
</a>
|
||||
<span class="text-base-content/50 text-sm">- Server-side validation with Ecto changesets</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-12 text-center text-base-content/50 text-sm">
|
||||
|
|
|
|||
78
example_project/lib/example_web/live/live_form.ex
Normal file
78
example_project/lib/example_web/live/live_form.ex
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
defmodule ExampleWeb.LiveForm do
|
||||
@moduledoc """
|
||||
LiveView demo for `useLiveForm()` composable.
|
||||
Demonstrates server-side validation with Ecto changesets and form reset on success.
|
||||
"""
|
||||
use ExampleWeb, :live_view
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inline embedded schema (no database required)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
defmodule Schema do
|
||||
@moduledoc false
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
embedded_schema do
|
||||
field(:name, :string)
|
||||
field(:email, :string)
|
||||
end
|
||||
|
||||
def changeset(schema \\ %__MODULE__{}, attrs) do
|
||||
schema
|
||||
|> cast(attrs, [:name, :email])
|
||||
|> validate_required([:name, :email])
|
||||
|> validate_format(:email, ~r/@/, message: "must contain @")
|
||||
|> validate_length(:name, min: 2, message: "must be at least 2 characters")
|
||||
end
|
||||
end
|
||||
|
||||
defp empty_form do
|
||||
Ecto.Changeset.cast(%Schema{name: "", email: ""}, %{}, [:name, :email])
|
||||
|> to_form(as: "form_data")
|
||||
end
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LiveView callbacks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, assign(socket, form: empty_form())}
|
||||
end
|
||||
|
||||
def handle_event("validate", params, socket) do
|
||||
attrs = params["form_data"] || %{}
|
||||
|
||||
form =
|
||||
Schema.changeset(%Schema{}, attrs)
|
||||
|> to_form(as: "form_data", action: :validate)
|
||||
|
||||
{:noreply, assign(socket, form: form)}
|
||||
end
|
||||
|
||||
def handle_event("submit", params, socket) do
|
||||
attrs = params["form_data"] || %{}
|
||||
changeset = Schema.changeset(%Schema{}, attrs)
|
||||
|
||||
if changeset.valid? do
|
||||
# Successful submit — tell the client to reset, return a clean form.
|
||||
{:reply, %{reset: true}, assign(socket, form: empty_form())}
|
||||
else
|
||||
form = changeset |> to_form(as: "form_data", action: :validate)
|
||||
{:reply, %{}, assign(socket, form: form)}
|
||||
end
|
||||
end
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="flex flex-col justify-center items-center gap-6 p-6">
|
||||
<h2 class="text-center text-2xl font-light my-4">Form (useLiveForm)</h2>
|
||||
<p class="text-sm text-base-content/50 text-center max-w-md">
|
||||
Server-side Ecto changeset validation with debounced change events and automatic form reset on success.
|
||||
</p>
|
||||
<.svelte name="FormDemo" props={%{form: @form}} socket={@socket} />
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
@ -62,6 +62,21 @@ defmodule ExampleWeb.Streams do
|
|||
{:noreply, stream_insert(socket, :items, updated)}
|
||||
end
|
||||
|
||||
def handle_event("add_capped_item", _params, socket) do
|
||||
new_item = %{
|
||||
id: socket.assigns.next_id,
|
||||
name: "Capped #{socket.assigns.next_id}",
|
||||
description: "Stream keeps last 3 items"
|
||||
}
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> stream_insert(:items, new_item, limit: -3)
|
||||
|> assign(:next_id, socket.assigns.next_id + 1)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("clear_stream", _params, socket) do
|
||||
{:noreply, stream(socket, :items, [], reset: true)}
|
||||
end
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ defmodule ExampleWeb.Router do
|
|||
live "/live-client-side-loading", LiveClientSideLoading
|
||||
# Ecto Examples
|
||||
live "/live-notes-otp", LiveNotesOtp
|
||||
live "/live-form", LiveForm
|
||||
# not referenced in app.html.heex:
|
||||
live "/live-composition", LiveComposition
|
||||
end
|
||||
|
|
|
|||
|
|
@ -33,6 +33,15 @@ defmodule ExampleWeb.LiveChatTest do
|
|||
|> 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
|
||||
session
|
||||
|> visit("/live-chat")
|
||||
|> fill_in(Query.css("[data-testid='chat-join-name']"), with: "Alice")
|
||||
|> click(Query.css("[data-testid='chat-join-form'] button", text: "Join"))
|
||||
|> assert_has(Query.css("[data-testid='chat-message-input']"))
|
||||
|> refute_has(Query.css("[data-testid='chat-reconnecting']"))
|
||||
end
|
||||
|
||||
test "joining with a name shows chat UI with message input", %{session: session} do
|
||||
session =
|
||||
session
|
||||
|
|
|
|||
98
example_project/test/example_web/live/live_form_test.exs
Normal file
98
example_project/test/example_web/live/live_form_test.exs
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
defmodule ExampleWeb.LiveFormTest do
|
||||
@moduledoc """
|
||||
E2E tests for the LiveForm LiveView (/live-form).
|
||||
Validates useLiveForm() composable: initial render, server validation,
|
||||
and successful submit with form reset.
|
||||
"""
|
||||
use ExampleWeb.FeatureCase, async: false
|
||||
|
||||
@moduletag :e2e
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Wait for an element with the given testid to appear (retries up to 3s).
|
||||
defp wait_for(session, testid, attempts \\ 30)
|
||||
defp wait_for(_session, testid, 0), do: raise("timeout waiting for [data-testid='#{testid}']")
|
||||
|
||||
defp wait_for(session, testid, attempts) do
|
||||
els = session |> all(Query.css("[data-testid='#{testid}']"))
|
||||
|
||||
if length(els) > 0 do
|
||||
session
|
||||
else
|
||||
:timer.sleep(100)
|
||||
wait_for(session, testid, attempts - 1)
|
||||
end
|
||||
end
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
test "initial form renders with inputs, no errors, and submit button", %{session: session} do
|
||||
session
|
||||
|> visit("/live-form")
|
||||
|> wait_for("form-name-input")
|
||||
|> assert_has(Query.css("[data-testid='form-name-input']"))
|
||||
|> assert_has(Query.css("[data-testid='form-email-input']"))
|
||||
|> assert_has(Query.css("[data-testid='form-submit-btn']"))
|
||||
|> refute_has(Query.css("[data-testid='form-name-error']"))
|
||||
|> refute_has(Query.css("[data-testid='form-email-error']"))
|
||||
end
|
||||
|
||||
test "server validation: submitting empty form shows required errors", %{session: session} do
|
||||
session =
|
||||
session
|
||||
|> visit("/live-form")
|
||||
|> wait_for("form-submit-btn")
|
||||
|> click(Query.css("[data-testid='form-submit-btn']"))
|
||||
|
||||
# Wait for server round-trip to deliver errors.
|
||||
:timer.sleep(500)
|
||||
|
||||
session
|
||||
|> assert_has(Query.css("[data-testid='form-name-error']"))
|
||||
|> assert_has(Query.css("[data-testid='form-email-error']"))
|
||||
end
|
||||
|
||||
test "server validation: typing invalid email shows error after debounce", %{session: session} do
|
||||
session =
|
||||
session
|
||||
|> visit("/live-form")
|
||||
|> wait_for("form-name-input")
|
||||
|> fill_in(Query.css("[data-testid='form-name-input']"), with: "Alice")
|
||||
|> fill_in(Query.css("[data-testid='form-email-input']"), with: "not-an-email")
|
||||
|
||||
# Wait for debounce (300ms) + server round-trip (allow 600ms total).
|
||||
:timer.sleep(600)
|
||||
|
||||
session
|
||||
|> assert_has(Query.css("[data-testid='form-email-error']"))
|
||||
end
|
||||
|
||||
test "submitting valid form resets fields (re-submit verifies empty values)", %{session: session} do
|
||||
session =
|
||||
session
|
||||
|> visit("/live-form")
|
||||
|> wait_for("form-name-input")
|
||||
|> fill_in(Query.css("[data-testid='form-name-input']"), with: "Alice")
|
||||
|> fill_in(Query.css("[data-testid='form-email-input']"), with: "alice@example.com")
|
||||
|> click(Query.css("[data-testid='form-submit-btn']"))
|
||||
|
||||
# Wait for first submit to complete and form to reset.
|
||||
:timer.sleep(600)
|
||||
|
||||
# Submit again with now-empty fields — server should reject with validation errors.
|
||||
# This proves the form was reset (values are empty, not "Alice" / "alice@...").
|
||||
session =
|
||||
session
|
||||
|> click(Query.css("[data-testid='form-submit-btn']"))
|
||||
|
||||
:timer.sleep(500)
|
||||
|
||||
session
|
||||
|> assert_has(Query.css("[data-testid='form-name-error']"))
|
||||
end
|
||||
end
|
||||
|
|
@ -103,7 +103,6 @@ defmodule ExampleWeb.StreamsTest do
|
|||
|> click(Query.css("[data-testid='update-1']"))
|
||||
|
||||
# Still exactly 3 items — no duplication
|
||||
items = all(session, Query.css("[data-testid^='item-'][data-testid$='-1'], [data-testid='item-1'], [data-testid='item-2'], [data-testid='item-3']"))
|
||||
session |> find(Query.css("[data-testid='item-1']"))
|
||||
session |> find(Query.css("[data-testid='item-2']"))
|
||||
session |> find(Query.css("[data-testid='item-3']"))
|
||||
|
|
@ -115,6 +114,27 @@ defmodule ExampleWeb.StreamsTest do
|
|||
session |> find(Query.css("[data-testid='item-count']", text: "Items (3)"))
|
||||
end
|
||||
|
||||
test "stream limit enforced: capped insert keeps only last 3 items", %{session: session} do
|
||||
session =
|
||||
session
|
||||
|> visit("/streams")
|
||||
# Initial state: items 1, 2, 3. Click "Add Capped (max 3)" once to add item 4.
|
||||
# With limit: -3, the stream keeps the last 3 → items 2, 3, 4.
|
||||
|> click(Query.css("[data-testid='add-capped-button']"))
|
||||
|
||||
# Items 2, 3, 4 are present
|
||||
session |> find(Query.css("[data-testid='item-2']"))
|
||||
session |> find(Query.css("[data-testid='item-3']"))
|
||||
session |> find(Query.css("[data-testid='item-4']"))
|
||||
|
||||
# Item 1 was evicted by the limit
|
||||
items_1 = all(session, Query.css("[data-testid='item-1']"))
|
||||
assert items_1 == []
|
||||
|
||||
# Total item count is 3
|
||||
session |> find(Query.css("[data-testid='item-count']", text: "Items (3)"))
|
||||
end
|
||||
|
||||
test "sequential add and remove maintains correct state", %{session: session} do
|
||||
session =
|
||||
session
|
||||
|
|
|
|||
|
|
@ -348,7 +348,7 @@ defmodule LiveSvelte do
|
|||
end
|
||||
|
||||
# Generates JSON Patch ops for a single %LiveStream{}.
|
||||
# Handles both LV 0.18.x (3-tuple inserts, no reset?) and LV 1.0.x (4-tuple inserts, has reset?/limit).
|
||||
# Handles LV 0.18.x (3-tuple), LV 1.0.x (4-tuple), and LV ≥ 1.0.x (5-tuple with update_only).
|
||||
# Op order: reset → deletes → inserts (each prepended, then list reversed).
|
||||
defp generate_stream_patches(stream_name, stream) do
|
||||
reset? = Map.get(stream, :reset?, false)
|
||||
|
|
@ -368,14 +368,27 @@ defmodule LiveSvelte do
|
|||
|> Enum.reverse()
|
||||
|> Enum.reduce(patches, fn insert, acc ->
|
||||
case insert do
|
||||
{dom_id, at, item, limit, update_only} ->
|
||||
item_map = encode_stream_item(item, dom_id)
|
||||
|
||||
acc =
|
||||
if update_only do
|
||||
[%{op: "replace", path: "/#{stream_name}/$$#{dom_id}", value: item_map} | acc]
|
||||
else
|
||||
at_path = if at == -1, do: "-", else: to_string(at)
|
||||
[%{op: "upsert", path: "/#{stream_name}/#{at_path}", value: item_map} | acc]
|
||||
end
|
||||
|
||||
if limit, do: [%{op: "limit", path: "/#{stream_name}", value: limit} | acc], else: acc
|
||||
|
||||
{dom_id, at, item, limit} ->
|
||||
item_map = Map.put(item, :__dom_id, dom_id)
|
||||
item_map = encode_stream_item(item, dom_id)
|
||||
at_path = if at == -1, do: "-", else: to_string(at)
|
||||
acc = [%{op: "upsert", path: "/#{stream_name}/#{at_path}", value: item_map} | acc]
|
||||
if limit, do: [%{op: "limit", path: "/#{stream_name}", value: limit} | acc], else: acc
|
||||
|
||||
{dom_id, at, item} ->
|
||||
item_map = Map.put(item, :__dom_id, dom_id)
|
||||
item_map = encode_stream_item(item, dom_id)
|
||||
at_path = if at == -1, do: "-", else: to_string(at)
|
||||
[%{op: "upsert", path: "/#{stream_name}/#{at_path}", value: item_map} | acc]
|
||||
end
|
||||
|
|
@ -384,6 +397,15 @@ defmodule LiveSvelte do
|
|||
Enum.reverse(patches)
|
||||
end
|
||||
|
||||
# Encodes a stream item via LiveSvelte.Encoder before attaching __dom_id.
|
||||
# Encoding MUST happen first so that @derive {only: [...]} restrictions are applied
|
||||
# before __dom_id is added (otherwise __dom_id could be stripped by the struct encoder).
|
||||
defp encode_stream_item(item, dom_id) do
|
||||
item
|
||||
|> LiveSvelte.Encoder.encode([])
|
||||
|> Map.put(:__dom_id, dom_id)
|
||||
end
|
||||
|
||||
# Encodes structs via LiveSvelte.Encoder so Jsonpatch can compare them.
|
||||
defp encode_for_diff(struct) when is_struct(struct), do: LiveSvelte.Encoder.encode(struct)
|
||||
defp encode_for_diff(other), do: other
|
||||
|
|
|
|||
|
|
@ -1,7 +1,15 @@
|
|||
# Struct used to test @derive {LiveSvelte.Encoder, only: [...]} in stream items.
|
||||
# Must be defined at file level (compile-time) for @derive to work.
|
||||
defmodule LiveSvelte.StreamsTest.SecretItem do
|
||||
@derive {LiveSvelte.Encoder, only: [:id, :name]}
|
||||
defstruct [:id, :name, :secret]
|
||||
end
|
||||
|
||||
defmodule LiveSvelte.StreamsTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Phoenix.LiveView.LiveStream
|
||||
alias LiveSvelte.StreamsTest.SecretItem
|
||||
|
||||
# Build a %LiveStream{} compatible with library's phoenix_live_view 0.18.15.
|
||||
# Inserts are 3-tuples {dom_id, at, item} in 0.18.15 and 4-tuples {dom_id, at, item, limit}
|
||||
|
|
@ -150,6 +158,28 @@ defmodule LiveSvelte.StreamsTest do
|
|||
upsert_ops = Enum.filter(diff, fn op -> Enum.at(op, 0) == "upsert" end)
|
||||
assert length(upsert_ops) == 2
|
||||
end
|
||||
|
||||
test "4-tuple insert with non-nil limit emits limit op" do
|
||||
stream = make_stream(inserts: [{"items-1", -1, %{id: 1, name: "Alice"}, 10}])
|
||||
assigns = base_assigns() |> Map.put(:items, stream)
|
||||
html = render_html(assigns)
|
||||
diff = decode_streams_diff(html)
|
||||
|
||||
limit_op = Enum.find(diff, fn op -> Enum.at(op, 0) == "limit" end)
|
||||
assert limit_op != nil, "expected limit op"
|
||||
assert Enum.at(limit_op, 1) == "/items"
|
||||
assert Enum.at(limit_op, 2) == 10
|
||||
end
|
||||
|
||||
test "4-tuple insert with nil limit does not emit limit op" do
|
||||
stream = make_stream(inserts: [{"items-1", -1, %{id: 1, name: "Alice"}, nil}])
|
||||
assigns = base_assigns() |> Map.put(:items, stream)
|
||||
html = render_html(assigns)
|
||||
diff = decode_streams_diff(html)
|
||||
|
||||
limit_op = Enum.find(diff, fn op -> Enum.at(op, 0) == "limit" end)
|
||||
assert limit_op == nil, "must not emit limit op when limit is nil"
|
||||
end
|
||||
end
|
||||
|
||||
describe "delete ops" do
|
||||
|
|
@ -175,6 +205,130 @@ defmodule LiveSvelte.StreamsTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "struct encoding in stream items" do
|
||||
test "struct with @derive only: [...] does not expose restricted fields in upsert value" do
|
||||
item = %SecretItem{id: 1, name: "Alice", secret: "hidden_password"}
|
||||
stream = make_stream(inserts: [{"items-1", -1, item}])
|
||||
assigns = base_assigns() |> Map.put(:items, stream)
|
||||
html = render_html(assigns)
|
||||
diff = decode_streams_diff(html)
|
||||
|
||||
upsert_op = Enum.find(diff, fn op -> Enum.at(op, 0) == "upsert" end)
|
||||
assert upsert_op != nil, "expected upsert op"
|
||||
value = Enum.at(upsert_op, 2)
|
||||
|
||||
assert value["id"] == 1
|
||||
assert value["name"] == "Alice"
|
||||
assert value["__dom_id"] == "items-1"
|
||||
refute Map.has_key?(value, "secret"), "sensitive field must not appear in stream diff"
|
||||
end
|
||||
|
||||
test "struct encoding does not lose __dom_id even when only: [...] is used" do
|
||||
item = %SecretItem{id: 42, name: "Bob", secret: "top_secret"}
|
||||
stream = make_stream(inserts: [{"items-42", -1, item}])
|
||||
assigns = base_assigns() |> Map.put(:items, stream)
|
||||
html = render_html(assigns)
|
||||
diff = decode_streams_diff(html)
|
||||
|
||||
upsert_op = Enum.find(diff, fn op -> Enum.at(op, 0) == "upsert" end)
|
||||
value = Enum.at(upsert_op, 2)
|
||||
assert value["__dom_id"] == "items-42", "__dom_id must always be present"
|
||||
end
|
||||
|
||||
test "plain map stream items still work after encoder integration" do
|
||||
item = %{id: 99, name: "Plain", extra: "visible"}
|
||||
stream = make_stream(inserts: [{"items-99", -1, item}])
|
||||
assigns = base_assigns() |> Map.put(:items, stream)
|
||||
html = render_html(assigns)
|
||||
diff = decode_streams_diff(html)
|
||||
|
||||
upsert_op = Enum.find(diff, fn op -> Enum.at(op, 0) == "upsert" end)
|
||||
value = Enum.at(upsert_op, 2)
|
||||
assert value["id"] == 99
|
||||
assert value["name"] == "Plain"
|
||||
assert value["extra"] == "visible"
|
||||
assert value["__dom_id"] == "items-99"
|
||||
end
|
||||
end
|
||||
|
||||
describe "5-tuple inserts (update_only)" do
|
||||
test "update_only: true generates replace op at $$dom_id path" do
|
||||
stream = make_stream(inserts: [{"items-1", -1, %{id: 1, name: "Updated"}, nil, true}])
|
||||
assigns = base_assigns() |> Map.put(:items, stream)
|
||||
html = render_html(assigns)
|
||||
diff = decode_streams_diff(html)
|
||||
|
||||
replace_op = Enum.find(diff, fn op ->
|
||||
Enum.at(op, 0) == "replace" && String.contains?(Enum.at(op, 1) || "", "$$items-1")
|
||||
end)
|
||||
assert replace_op != nil, "expected replace op at $$dom_id path for update_only: true"
|
||||
assert Enum.at(replace_op, 1) == "/items/$$items-1"
|
||||
|
||||
# Must NOT generate upsert for update_only: true
|
||||
upsert_op = Enum.find(diff, fn op -> Enum.at(op, 0) == "upsert" end)
|
||||
assert upsert_op == nil, "must NOT generate upsert when update_only: true"
|
||||
end
|
||||
|
||||
test "update_only: false with 5-tuple generates upsert op" do
|
||||
stream = make_stream(inserts: [{"items-2", -1, %{id: 2, name: "New"}, nil, false}])
|
||||
assigns = base_assigns() |> Map.put(:items, stream)
|
||||
html = render_html(assigns)
|
||||
diff = decode_streams_diff(html)
|
||||
|
||||
upsert_op = Enum.find(diff, fn op -> Enum.at(op, 0) == "upsert" end)
|
||||
assert upsert_op != nil, "expected upsert op when update_only: false"
|
||||
assert Enum.at(upsert_op, 1) == "/items/-"
|
||||
end
|
||||
|
||||
test "5-tuple with limit emits limit op" do
|
||||
stream = make_stream(inserts: [{"items-1", -1, %{id: 1, name: "A"}, 5, false}])
|
||||
assigns = base_assigns() |> Map.put(:items, stream)
|
||||
html = render_html(assigns)
|
||||
diff = decode_streams_diff(html)
|
||||
|
||||
limit_op = Enum.find(diff, fn op -> Enum.at(op, 0) == "limit" end)
|
||||
assert limit_op != nil, "expected limit op"
|
||||
assert Enum.at(limit_op, 1) == "/items"
|
||||
assert Enum.at(limit_op, 2) == 5
|
||||
end
|
||||
|
||||
test "5-tuple with nil limit does not emit limit op" do
|
||||
stream = make_stream(inserts: [{"items-1", -1, %{id: 1, name: "A"}, nil, false}])
|
||||
assigns = base_assigns() |> Map.put(:items, stream)
|
||||
html = render_html(assigns)
|
||||
diff = decode_streams_diff(html)
|
||||
|
||||
limit_op = Enum.find(diff, fn op -> Enum.at(op, 0) == "limit" end)
|
||||
assert limit_op == nil, "must not emit limit op when limit is nil"
|
||||
end
|
||||
|
||||
test "5-tuple with negative limit emits negative limit op (keep last N)" do
|
||||
stream = make_stream(inserts: [{"items-1", -1, %{id: 1, name: "A"}, -3, false}])
|
||||
assigns = base_assigns() |> Map.put(:items, stream)
|
||||
html = render_html(assigns)
|
||||
diff = decode_streams_diff(html)
|
||||
|
||||
limit_op = Enum.find(diff, fn op -> Enum.at(op, 0) == "limit" end)
|
||||
assert limit_op != nil, "expected limit op"
|
||||
assert Enum.at(limit_op, 1) == "/items"
|
||||
assert Enum.at(limit_op, 2) == -3
|
||||
end
|
||||
|
||||
test "update_only: true value includes __dom_id" do
|
||||
stream = make_stream(inserts: [{"items-5", -1, %{id: 5, name: "X"}, nil, true}])
|
||||
assigns = base_assigns() |> Map.put(:items, stream)
|
||||
html = render_html(assigns)
|
||||
diff = decode_streams_diff(html)
|
||||
|
||||
replace_op = Enum.find(diff, fn op ->
|
||||
Enum.at(op, 0) == "replace" && String.contains?(Enum.at(op, 1) || "", "$$items-5")
|
||||
end)
|
||||
value = Enum.at(replace_op, 2)
|
||||
assert value["__dom_id"] == "items-5"
|
||||
assert value["id"] == 5
|
||||
end
|
||||
end
|
||||
|
||||
describe "multiple stream assigns" do
|
||||
test "two stream assigns both appear in streams-diff" do
|
||||
stream_a = make_stream(inserts: [{"a-1", -1, %{id: 1, name: "A"}}])
|
||||
|
|
|
|||
Loading…
Reference in a new issue