diff --git a/assets/js/live_svelte/composables.test.ts b/assets/js/live_svelte/composables.test.ts new file mode 100644 index 0000000..d8f63ce --- /dev/null +++ b/assets/js/live_svelte/composables.test.ts @@ -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 + ) + }) + }) +}) diff --git a/assets/js/live_svelte/composables.ts b/assets/js/live_svelte/composables.ts new file mode 100644 index 0000000..50a9966 --- /dev/null +++ b/assets/js/live_svelte/composables.ts @@ -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 + * + * ``` + */ +export function useLiveSvelte(): UseLiveSvelteResult { + const live = getContext(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(...args), + pushEventTo: (...args: Parameters) => 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 + * + * ``` + */ +export function useLiveEvent(event: string, callback: (payload: unknown) => void): void { + const live = getContext(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 + * + * {#if !conn.connected} + *

Reconnecting…

+ * {/if} + * ``` + */ +export function useLiveConnection(): UseLiveConnectionResult { + const state = getContext(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 + * + * + * + * ``` + */ +export function useLiveNavigation(): UseLiveNavigationResult { + const live = getContext(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, + 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 } +} diff --git a/assets/js/live_svelte/hooks.svelte.js b/assets/js/live_svelte/hooks.svelte.js index c6802a5..bf351d6 100644 --- a/assets/js/live_svelte/hooks.svelte.js +++ b/assets/js/live_svelte/hooks.svelte.js @@ -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 }) }, diff --git a/assets/js/live_svelte/index.ts b/assets/js/live_svelte/index.ts index 5a48451..5faa9d8 100644 --- a/assets/js/live_svelte/index.ts +++ b/assets/js/live_svelte/index.ts @@ -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"; diff --git a/assets/js/live_svelte/jsonPatch.test.ts b/assets/js/live_svelte/jsonPatch.test.ts index 2212513..17d5f45 100644 --- a/assets/js/live_svelte/jsonPatch.test.ts +++ b/assets/js/live_svelte/jsonPatch.test.ts @@ -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 = { 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]) + }) }) // --------------------------------------------------------------------------- diff --git a/assets/js/live_svelte/types.d.ts b/assets/js/live_svelte/types.d.ts index 97f2e3f..038cf74 100644 --- a/assets/js/live_svelte/types.d.ts +++ b/assets/js/live_svelte/types.d.ts @@ -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, 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 = { + [K in keyof T]?: string[] | Record; +}; + +/** + * 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 { + name: string; + values: T; + errors: FormErrors; + 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) => Record; +} + +/** Attributes to spread onto an `` 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 { + 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 extends Readable> { + set(value: V): void; + update(updater: (currentValue: V) => V): void; + field(subPath: string, options?: FieldOptions): FormField; + fieldArray(subPath: string): FormFieldArray; +} + +/** + * A reactive array-field store with per-item field stores and + * add/remove/move operations. + */ +export interface FormFieldArray extends Readable> { + set(value: V[]): void; + fields: Readable[]>; + add(item?: Partial): void; + remove(index: number): void; + move(from: number, to: number): void; +} + +/** Return value of `useLiveForm`. */ +export interface UseLiveFormReturn { + isValid: Readable; + isDirty: Readable; + isTouched: Readable; + isValidating: Readable; + submitCount: Readable; + initialValues: Readonly; + field(path: string, options?: FieldOptions): FormField; + fieldArray(path: string): FormFieldArray; + submit(): Promise; + reset(): void; + sync(newForm: Form): void; +} + +/** + * Bind to an Ecto changeset form prop with reactive field instances. + * + * @example + * ```svelte + * + * + * ``` + */ +export declare function useLiveForm( + form: Form, + options?: FormOptions +): UseLiveFormReturn; + diff --git a/assets/js/live_svelte/useLiveForm.test.ts b/assets/js/live_svelte/useLiveForm.test.ts new file mode 100644 index 0000000..cf66a23 --- /dev/null +++ b/assets/js/live_svelte/useLiveForm.test.ts @@ -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("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) => ({ ...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) => ({ ...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, + 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("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) + }) +}) diff --git a/assets/js/live_svelte/useLiveForm.ts b/assets/js/live_svelte/useLiveForm.ts new file mode 100644 index 0000000..236d19f --- /dev/null +++ b/assets/js/live_svelte/useLiveForm.ts @@ -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 + * + * + * ``` + */ + +import { writable, derived, get, type Readable, type Writable } from "svelte/store" +import { useLiveSvelte } from "./composables" + +// --------------------------------------------------------------------------- +// Utility functions +// --------------------------------------------------------------------------- + +function deepClone(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 = { + [K in keyof T]?: string[] | Record +} + +/** + * 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 { + /** Form name used as the key in `pushEvent` payloads: `{ [name]: values }` */ + name: string + /** Current field values */ + values: T + /** Validation errors per field */ + errors: FormErrors + /** 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) => Record +} + +/** Attributes to spread onto an `` 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 { + 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 extends Readable> { + 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 + /** Access a nested array sub-field by dot-path relative to this field. */ + fieldArray(subPath: string): FormFieldArray +} + +/** + * A reactive array-field store. Provides `fields` (array of item field stores) + * and `add`/`remove`/`move` operations. + */ +export interface FormFieldArray extends Readable> { + set(value: V[]): void + /** Reactive store of per-item `FormField` instances. Subscribe via `$itemFields`. */ + fields: Readable[]> + add(item?: Partial): void + remove(index: number): void + move(from: number, to: number): void +} + +/** Return value of `useLiveForm`. */ +export interface UseLiveFormReturn { + isValid: Readable + isDirty: Readable + isTouched: Readable + isValidating: Readable + submitCount: Readable + /** Frozen snapshot of initial values. */ + initialValues: Readonly + field(path: string, options?: FieldOptions): FormField + fieldArray(path: string): FormFieldArray + /** Send submit event to the LiveView. Handles `{ reset: true }` reply. */ + submit(): Promise + /** 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): void +} + +// --------------------------------------------------------------------------- +// Implementation +// --------------------------------------------------------------------------- + +export function useLiveForm( + form: Form, + options: FormOptions = {} +): UseLiveFormReturn { + const { + changeEvent = null, + submitEvent = "submit", + debounceInMiliseconds = 300, + prepareData = (d: Record) => d, + } = options + + // Graceful degradation: composable works without LiveSvelte context (SSR, tests without mock). + let liveCtx: ReturnType | 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 + + // Core reactive stores. + const currentValues: Writable = writable(deepClone(form.values)) + const currentErrors: Writable> = writable(deepClone(form.errors)) + const touchedPaths: Writable> = writable(new Set()) + const submitCountStore: Writable = writable(0) + const isValidatingStore: Writable = writable(false) + + let debounceTimer: ReturnType | null = null + + // Form-level derived stores. + const isValid: Readable = derived( + currentErrors, + ($errors) => !hasAnyErrors($errors) + ) + + const isDirty: Readable = derived( + currentValues, + ($values) => JSON.stringify($values) !== JSON.stringify(initialSnapshot) + ) + + const isTouched: Readable = 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) + 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>() + const fieldArrayCache = new Map>() + + function createField(path: string, opts: FieldOptions = {}): FormField { + const cacheKey = `${path}::${JSON.stringify(opts)}` + if (fieldCache.has(cacheKey)) return fieldCache.get(cacheKey) as FormField + + 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> = 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 = { + 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 { + return createField(`${path}.${subPath}`, subOpts) + }, + + fieldArray(subPath: string): FormFieldArray { + return createFieldArray(`${path}.${subPath}`) + }, + } + + fieldCache.set(cacheKey, fieldStore) + return fieldStore + } + + function createFieldArray(path: string): FormFieldArray { + if (fieldArrayCache.has(path)) return fieldArrayCache.get(path) as FormFieldArray + + const keys = parsePath(path) + const baseField = createField(path) as FormField + + const fieldsStore: Readable[]> = derived(currentValues, ($values) => { + const arr: V[] = getByPath($values, keys) ?? [] + return arr.map((_: V, i: number) => createField(`${path}[${i}]`)) + }) + + const arrayStore: FormFieldArray = { + subscribe: baseField.subscribe, + + set(value: V[]) { + baseField.set(value) + }, + + fields: fieldsStore, + + add(item?: Partial) { + 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 { + // 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) + + 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) + touchedPaths.set(new Set()) + submitCountStore.set(0) + isValidatingStore.set(false) + } + + function sync(newForm: Form): 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, + } +} diff --git a/assets/package-lock.json b/assets/package-lock.json index 485a9a7..d755445 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -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", diff --git a/assets/package.json b/assets/package.json index 4a872ab..be38148 100644 --- a/assets/package.json +++ b/assets/package.json @@ -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", diff --git a/assets/vitest.config.js b/assets/vitest.config.js index bfb5f39..33d4438 100644 --- a/assets/vitest.config.js +++ b/assets/vitest.config.js @@ -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', diff --git a/example_project/assets/svelte/Chat.svelte b/example_project/assets/svelte/Chat.svelte index 4138b7b..c67b332 100644 --- a/example_project/assets/svelte/Chat.svelte +++ b/example_project/assets/svelte/Chat.svelte @@ -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 = "" } @@ -32,6 +37,11 @@ {name} + {#if !conn.connected} +
+ Reconnecting… +
+ {/if}
    {#each messages as message (message.id)} {@const me = message.name === name} diff --git a/example_project/assets/svelte/FormDemo.svelte b/example_project/assets/svelte/FormDemo.svelte new file mode 100644 index 0000000..0a7a383 --- /dev/null +++ b/example_project/assets/svelte/FormDemo.svelte @@ -0,0 +1,81 @@ + + +
    +
    { + e.preventDefault() + liveForm.submit() + }} + class="card bg-base-100 shadow-md border border-base-300/50 overflow-hidden" + > +
    + Contact form + + + + + + + +
    + + + {$isValid ? "valid" : "invalid"} + +
    +
    +
    +
    diff --git a/example_project/assets/svelte/StreamDemo.svelte b/example_project/assets/svelte/StreamDemo.svelte index b023823..b78cf30 100644 --- a/example_project/assets/svelte/StreamDemo.svelte +++ b/example_project/assets/svelte/StreamDemo.svelte @@ -34,6 +34,10 @@ function updateItem(id) { live.pushEvent("update_item", { id }) } + + function addCappedItem() { + live.pushEvent("add_capped_item", {}) + }
    @@ -77,6 +81,9 @@ +
    diff --git a/example_project/lib/example_web/components/layouts.ex b/example_project/lib/example_web/components/layouts.ex index e4f8d2a..fcab74c 100644 --- a/example_project/lib/example_web/components/layouts.ex +++ b/example_project/lib/example_web/components/layouts.ex @@ -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 diff --git a/example_project/lib/example_web/controllers/page_html/home.html.heex b/example_project/lib/example_web/controllers/page_html/home.html.heex index 00b6ad9..9d4d453 100644 --- a/example_project/lib/example_web/controllers/page_html/home.html.heex +++ b/example_project/lib/example_web/controllers/page_html/home.html.heex @@ -219,6 +219,24 @@
+ + <%!-- Composables --%> +
+
+

+ 7 + Composables +

+ +
+
diff --git a/example_project/lib/example_web/live/live_form.ex b/example_project/lib/example_web/live/live_form.ex new file mode 100644 index 0000000..bae1e43 --- /dev/null +++ b/example_project/lib/example_web/live/live_form.ex @@ -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""" +
+

Form (useLiveForm)

+

+ Server-side Ecto changeset validation with debounced change events and automatic form reset on success. +

+ <.svelte name="FormDemo" props={%{form: @form}} socket={@socket} /> +
+ """ + end +end diff --git a/example_project/lib/example_web/live/streams.ex b/example_project/lib/example_web/live/streams.ex index ee401ac..7be7581 100644 --- a/example_project/lib/example_web/live/streams.ex +++ b/example_project/lib/example_web/live/streams.ex @@ -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 diff --git a/example_project/lib/example_web/router.ex b/example_project/lib/example_web/router.ex index c7df5d5..75a042d 100644 --- a/example_project/lib/example_web/router.ex +++ b/example_project/lib/example_web/router.ex @@ -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 diff --git a/example_project/test/example_web/live/live_chat_test.exs b/example_project/test/example_web/live/live_chat_test.exs index e11402c..0dfecac 100644 --- a/example_project/test/example_web/live/live_chat_test.exs +++ b/example_project/test/example_web/live/live_chat_test.exs @@ -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 diff --git a/example_project/test/example_web/live/live_form_test.exs b/example_project/test/example_web/live/live_form_test.exs new file mode 100644 index 0000000..3fb8469 --- /dev/null +++ b/example_project/test/example_web/live/live_form_test.exs @@ -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 diff --git a/example_project/test/example_web/live/streams_test.exs b/example_project/test/example_web/live/streams_test.exs index d14c3ca..5cf5776 100644 --- a/example_project/test/example_web/live/streams_test.exs +++ b/example_project/test/example_web/live/streams_test.exs @@ -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 diff --git a/lib/live_svelte.ex b/lib/live_svelte.ex index 0f94ac2..832ce15 100644 --- a/lib/live_svelte.ex +++ b/lib/live_svelte.ex @@ -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 diff --git a/test/streams_test.exs b/test/streams_test.exs index ede7e87..efd9807 100644 --- a/test/streams_test.exs +++ b/test/streams_test.exs @@ -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"}}])