From fa56ed97902704f15dcbe2fa9bb504ae6e21603c Mon Sep 17 00:00:00 2001 From: Denis Donici Date: Wed, 25 Feb 2026 09:16:04 +0200 Subject: [PATCH] chore: added file upload --- assets/js/live_svelte/index.ts | 2 + assets/js/live_svelte/types.d.ts | 106 ++++ assets/js/live_svelte/useLiveUpload.test.ts | 527 ++++++++++++++++++ assets/js/live_svelte/useLiveUpload.ts | 289 ++++++++++ .../assets/svelte/UploadDemo.svelte | 153 +++++ .../lib/example_web/components/layouts.ex | 3 +- .../controllers/page_html/home.html.heex | 6 + .../lib/example_web/live/live_upload.ex | 56 ++ example_project/lib/example_web/router.ex | 1 + .../example_web/live/live_upload_test.exs | 136 +++++ 10 files changed, 1278 insertions(+), 1 deletion(-) create mode 100644 assets/js/live_svelte/useLiveUpload.test.ts create mode 100644 assets/js/live_svelte/useLiveUpload.ts create mode 100644 example_project/assets/svelte/UploadDemo.svelte create mode 100644 example_project/lib/example_web/live/live_upload.ex create mode 100644 example_project/test/example_web/live/live_upload_test.exs diff --git a/assets/js/live_svelte/index.ts b/assets/js/live_svelte/index.ts index 5faa9d8..d088b90 100644 --- a/assets/js/live_svelte/index.ts +++ b/assets/js/live_svelte/index.ts @@ -15,3 +15,5 @@ export type { FormFieldArray, UseLiveFormReturn, } from "./useLiveForm"; +export { useLiveUpload } from "./useLiveUpload"; +export type { UploadEntry, UploadConfig, UploadOptions, UseLiveUploadReturn } from "./useLiveUpload"; diff --git a/assets/js/live_svelte/types.d.ts b/assets/js/live_svelte/types.d.ts index 038cf74..5dc7438 100644 --- a/assets/js/live_svelte/types.d.ts +++ b/assets/js/live_svelte/types.d.ts @@ -253,3 +253,109 @@ export declare function useLiveForm( options?: FormOptions ): UseLiveFormReturn; +// --------------------------------------------------------------------------- +// useLiveUpload types +// Keep in sync with useLiveUpload.ts — that file is the source of truth. +// --------------------------------------------------------------------------- + +/** + * Shape of a single upload entry from Phoenix.LiveView.UploadEntry, + * encoded by LiveSvelte.Encoder. + */ +export interface UploadEntry { + /** Phoenix upload ref for this entry (e.g. "phx-ref-0"). */ + ref: string; + /** Original filename from the client. */ + client_name: string; + /** File size in bytes. */ + client_size: number; + /** MIME type. */ + client_type: string; + /** Upload progress 0–100. */ + progress: number; + /** Whether the upload has completed. */ + done: boolean; + /** Whether the entry passes accept/size validations. */ + valid: boolean; + /** Whether Phoenix has acknowledged (preflighted) this entry. */ + preflighted: boolean; + /** Entry-specific validation error messages. */ + errors: string[]; +} + +/** + * Shape of a Phoenix.LiveView.UploadConfig encoded by LiveSvelte.Encoder. + * Pass `@uploads.name` as a prop from the LiveView. + */ +export interface UploadConfig { + /** Phoenix upload ref (e.g. "phx-abc123"). */ + ref: string; + /** Upload name matching `allow_upload(:name, ...)` in the LiveView. */ + name: string; + /** Accepted file types (e.g. ".jpg,.png") or false for any. */ + accept: string | false; + /** Maximum number of concurrent uploads. */ + max_entries: number; + /** When true, uploads begin as soon as files are selected. */ + auto_upload: boolean; + /** Current upload entries. */ + entries: UploadEntry[]; + /** Top-level upload config errors (ref + error message pairs). */ + errors: { ref: string; error: string }[]; +} + +/** Options for `useLiveUpload`. */ +export interface UploadOptions { + /** Server event name for Phoenix phx-change (validation). Optional. */ + changeEvent?: string; + /** Server event name for Phoenix phx-submit (required). */ + submitEvent: string; +} + +/** Return value of `useLiveUpload`. */ +export interface UseLiveUploadReturn { + /** Reactive list of current upload entries from the server. */ + entries: Readable; + /** Overall upload progress 0–100 averaged across all entries. */ + progress: Readable; + /** True when the upload config has no top-level errors. */ + valid: Readable; + /** The underlying hidden `` element store. */ + inputEl: Readable; + /** Opens the native file-picker dialog. */ + showFilePicker(): void; + /** Enqueue files from an array or DataTransfer (for drag-drop). */ + addFiles(files: File[] | DataTransfer): void; + /** Dispatch a form submit event to trigger Phoenix upload (manual upload). */ + submit(): void; + /** Cancel a specific entry by ref, or all entries when omitted. */ + cancel(ref?: string): void; + /** Reset the hidden input value to clear the file queue. */ + clear(): void; + /** + * Merge an updated UploadConfig from the server into the composable. + * Call from a Svelte `$effect(() => { upload.sync(props.uploads.avatar) })`. + */ + sync(newConfig: UploadConfig): void; +} + +/** + * Bind to a Phoenix.LiveView.UploadConfig prop for reactive file upload management. + * + * @example + * ```svelte + * + * + * {#each $upload.entries as entry}{entry.client_name}{/each} + * ``` + */ +export declare function useLiveUpload( + uploadConfig: UploadConfig, + options: UploadOptions +): UseLiveUploadReturn; + diff --git a/assets/js/live_svelte/useLiveUpload.test.ts b/assets/js/live_svelte/useLiveUpload.test.ts new file mode 100644 index 0000000..2ccd305 --- /dev/null +++ b/assets/js/live_svelte/useLiveUpload.test.ts @@ -0,0 +1,527 @@ +/** + * Tests for useLiveUpload composable. + * + * Mocking strategy: + * - `svelte` module is mocked: getContext (for useLiveSvelte), onMount (auto-invoke callback), + * onDestroy (no-op). vi.mock is hoisted before imports. + * - The onMount mock invokes the callback immediately (synchronous) so DOM operations + * execute during each test without needing a real Svelte component lifecycle. + * - jsdom environment (from vitest.config.js) provides document.createElement, DataTransfer, etc. + */ + +import { vi, describe, it, expect, beforeEach, afterEach } from "vitest" +import { get } from "svelte/store" + +// jsdom does not implement DataTransfer. Polyfill it so addFiles() tests can run. +if (typeof DataTransfer === "undefined") { + class MockDataTransfer { + private _files: File[] = [] + items = { + add: (file: File) => { + this._files.push(file) + }, + } + get files(): FileList { + const arr = this._files + return Object.assign(arr, { + item: (i: number) => arr[i] ?? null, + }) as unknown as FileList + } + } + ;(globalThis as any).DataTransfer = MockDataTransfer +} + +// CRITICAL: vi.mock is hoisted before imports by Vitest. Must appear before +// any import of the module under test. +vi.mock("svelte", () => ({ + getContext: vi.fn(), + onMount: vi.fn((fn: () => (() => void) | void) => { + const cleanup = fn() + // Store cleanup on the mock for explicit teardown in tests if needed. + ;(vi.mocked(onMount) as any).__lastCleanup = cleanup + }), + onDestroy: vi.fn(), +})) + +import { getContext, onMount } from "svelte" +import { useLiveUpload } from "./useLiveUpload" +import type { UploadConfig, UploadEntry } from "./useLiveUpload" + +// --------------------------------------------------------------------------- +// Test fixtures +// --------------------------------------------------------------------------- + +const mockEl = document.createElement("div") +document.body.appendChild(mockEl) + +const mockPushEvent = vi.fn().mockReturnValue(1) +const mockLive = { + pushEvent: mockPushEvent, + pushEventTo: vi.fn(), + handleEvent: vi.fn().mockReturnValue(() => {}), + removeHandleEvent: vi.fn(), + upload: vi.fn(), + uploadTo: vi.fn(), + liveSocket: undefined, + el: mockEl, +} + +function makeEntry(partial: Partial = {}): UploadEntry { + return { + ref: "e1", + client_name: "test.txt", + client_size: 1024, + client_type: "text/plain", + progress: 0, + done: false, + valid: true, + preflighted: false, + errors: [], + ...partial, + } +} + +const baseConfig: UploadConfig = { + ref: "phx-ref-0", + name: "avatar", + accept: ".jpg,.png", + max_entries: 1, + auto_upload: false, + entries: [], + errors: [], +} + +// --------------------------------------------------------------------------- +// Setup / teardown +// --------------------------------------------------------------------------- + +beforeEach(() => { + vi.clearAllMocks() + vi.mocked(getContext).mockReturnValue(mockLive) + // Clear children of mockEl between tests. + mockEl.innerHTML = "" + ;(vi.mocked(onMount) as any).__lastCleanup = undefined +}) + +afterEach(() => { + // Run cleanup function if the onMount callback returned one. + const cleanup = (vi.mocked(onMount) as any).__lastCleanup + if (typeof cleanup === "function") cleanup() +}) + +// --------------------------------------------------------------------------- +// Initialization +// --------------------------------------------------------------------------- + +describe("initialization", () => { + it("returns all expected properties", () => { + const upload = useLiveUpload(baseConfig, { submitEvent: "save" }) + expect(upload).toHaveProperty("entries") + expect(upload).toHaveProperty("progress") + expect(upload).toHaveProperty("valid") + expect(upload).toHaveProperty("inputEl") + expect(upload).toHaveProperty("showFilePicker") + expect(upload).toHaveProperty("addFiles") + expect(upload).toHaveProperty("submit") + expect(upload).toHaveProperty("cancel") + expect(upload).toHaveProperty("clear") + expect(upload).toHaveProperty("sync") + }) + + it("onMount is called during composable initialisation", () => { + useLiveUpload(baseConfig, { submitEvent: "save" }) + expect(vi.mocked(onMount)).toHaveBeenCalledTimes(1) + }) + + it("creates hidden form appended to live.el", () => { + useLiveUpload(baseConfig, { submitEvent: "save" }) + const form = mockEl.querySelector("form") + expect(form).not.toBeNull() + expect(form?.style.display).toBe("none") + }) + + it("creates input with required Phoenix upload attributes", () => { + useLiveUpload(baseConfig, { submitEvent: "save" }) + const input = mockEl.querySelector("input[type=file]") as HTMLInputElement + expect(input).not.toBeNull() + expect(input.id).toBe(baseConfig.ref) + expect(input.name).toBe(baseConfig.name) + expect(input.getAttribute("data-phx-hook")).toBe("Phoenix.LiveFileUpload") + expect(input.getAttribute("data-phx-update")).toBe("ignore") + expect(input.getAttribute("data-phx-upload-ref")).toBe(baseConfig.ref) + }) + + it("sets accept attribute when config.accept is a string", () => { + useLiveUpload(baseConfig, { submitEvent: "save" }) + const input = mockEl.querySelector("input[type=file]") as HTMLInputElement + expect(input.accept).toBe(".jpg,.png") + }) + + it("does NOT set accept attribute when config.accept is false", () => { + const config = { ...baseConfig, accept: false as const } + useLiveUpload(config, { submitEvent: "save" }) + const input = mockEl.querySelector("input[type=file]") as HTMLInputElement + expect(input.accept).toBe("") + }) + + it("sets multiple attribute when max_entries > 1", () => { + const config = { ...baseConfig, max_entries: 3 } + useLiveUpload(config, { submitEvent: "save" }) + const input = mockEl.querySelector("input[type=file]") as HTMLInputElement + expect(input.multiple).toBe(true) + }) + + it("does NOT set multiple when max_entries = 1", () => { + useLiveUpload(baseConfig, { submitEvent: "save" }) + const input = mockEl.querySelector("input[type=file]") as HTMLInputElement + expect(input.multiple).toBe(false) + }) + + it("sets data-phx-auto-upload when auto_upload is true", () => { + const config = { ...baseConfig, auto_upload: true } + useLiveUpload(config, { submitEvent: "save" }) + const input = mockEl.querySelector("input[type=file]") as HTMLInputElement + expect(input.getAttribute("data-phx-auto-upload")).toBe("true") + }) + + it("does NOT set data-phx-auto-upload when auto_upload is false", () => { + useLiveUpload(baseConfig, { submitEvent: "save" }) + const input = mockEl.querySelector("input[type=file]") as HTMLInputElement + expect(input.getAttribute("data-phx-auto-upload")).toBeNull() + }) + + it("sets phx-change on form when changeEvent is provided", () => { + useLiveUpload(baseConfig, { changeEvent: "validate", submitEvent: "save" }) + const form = mockEl.querySelector("form") + expect(form?.getAttribute("phx-change")).toBe("validate") + }) + + it("does NOT set phx-change on form when changeEvent is omitted", () => { + useLiveUpload(baseConfig, { submitEvent: "save" }) + const form = mockEl.querySelector("form") + expect(form?.getAttribute("phx-change")).toBeNull() + }) + + it("sets phx-submit on form", () => { + useLiveUpload(baseConfig, { submitEvent: "save" }) + const form = mockEl.querySelector("form") + expect(form?.getAttribute("phx-submit")).toBe("save") + }) + + it("exposes inputEl store with the created input element", () => { + const upload = useLiveUpload(baseConfig, { submitEvent: "save" }) + const input = get(upload.inputEl) + expect(input).not.toBeNull() + expect(input?.tagName).toBe("INPUT") + }) +}) + +// --------------------------------------------------------------------------- +// Reactive stores — initial state +// --------------------------------------------------------------------------- + +describe("reactive stores — initial state", () => { + it("entries store is empty initially", () => { + const upload = useLiveUpload(baseConfig, { submitEvent: "save" }) + expect(get(upload.entries)).toEqual([]) + }) + + it("progress returns 0 when there are no entries", () => { + const upload = useLiveUpload(baseConfig, { submitEvent: "save" }) + expect(get(upload.progress)).toBe(0) + }) + + it("valid returns true when errors is empty", () => { + const upload = useLiveUpload(baseConfig, { submitEvent: "save" }) + expect(get(upload.valid)).toBe(true) + }) + + it("valid returns false when config has top-level errors", () => { + const config = { ...baseConfig, errors: [{ ref: "phx-ref-0", error: "too many files" }] } + const upload = useLiveUpload(config, { submitEvent: "save" }) + expect(get(upload.valid)).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// sync() +// --------------------------------------------------------------------------- + +describe("sync()", () => { + it("updates entries store when new config has entries", () => { + const upload = useLiveUpload(baseConfig, { submitEvent: "save" }) + const entry = makeEntry({ client_name: "img.jpg", progress: 50 }) + upload.sync({ ...baseConfig, entries: [entry] }) + expect(get(upload.entries)).toHaveLength(1) + expect(get(upload.entries)[0].client_name).toBe("img.jpg") + }) + + it("clears entries when synced with empty entries", () => { + const entry = makeEntry() + const configWithEntry = { ...baseConfig, entries: [entry] } + const upload = useLiveUpload(configWithEntry, { submitEvent: "save" }) + upload.sync(baseConfig) + expect(get(upload.entries)).toHaveLength(0) + }) + + it("updates progress after sync", () => { + const upload = useLiveUpload(baseConfig, { submitEvent: "save" }) + upload.sync({ + ...baseConfig, + entries: [ + makeEntry({ ref: "e1", progress: 60 }), + makeEntry({ ref: "e2", progress: 40 }), + ], + }) + expect(get(upload.progress)).toBe(50) + }) + + it("rounds fractional progress", () => { + const upload = useLiveUpload(baseConfig, { submitEvent: "save" }) + upload.sync({ + ...baseConfig, + entries: [ + makeEntry({ ref: "e1", progress: 33 }), + makeEntry({ ref: "e2", progress: 34 }), + makeEntry({ ref: "e3", progress: 34 }), + ], + }) + // (33+34+34)/3 = 33.666... → rounds to 34 + expect(get(upload.progress)).toBe(34) + }) + + it("updates valid after sync changes errors", () => { + const upload = useLiveUpload(baseConfig, { submitEvent: "save" }) + expect(get(upload.valid)).toBe(true) + upload.sync({ ...baseConfig, errors: [{ ref: "phx-ref-0", error: "too large" }] }) + expect(get(upload.valid)).toBe(false) + }) + + it("updates input ref attributes when entries change", () => { + const upload = useLiveUpload(baseConfig, { submitEvent: "save" }) + const input = get(upload.inputEl)! + expect(input.getAttribute("data-phx-active-refs")).toBe("") + const entry = makeEntry({ ref: "e99", done: false, preflighted: false }) + upload.sync({ ...baseConfig, entries: [entry] }) + expect(input.getAttribute("data-phx-active-refs")).toBe("e99") + }) + + it("sets data-phx-done-refs from done entries", () => { + const upload = useLiveUpload(baseConfig, { submitEvent: "save" }) + const input = get(upload.inputEl)! + const entry = makeEntry({ ref: "e1", done: true }) + upload.sync({ ...baseConfig, entries: [entry] }) + expect(input.getAttribute("data-phx-done-refs")).toBe("e1") + }) + + it("sets data-phx-preflighted-refs from preflighted entries", () => { + const upload = useLiveUpload(baseConfig, { submitEvent: "save" }) + const input = get(upload.inputEl)! + const entry = makeEntry({ ref: "e1", preflighted: true }) + upload.sync({ ...baseConfig, entries: [entry] }) + expect(input.getAttribute("data-phx-preflighted-refs")).toBe("e1") + }) +}) + +// --------------------------------------------------------------------------- +// showFilePicker() +// --------------------------------------------------------------------------- + +describe("showFilePicker()", () => { + it("calls click() on the hidden input", () => { + const upload = useLiveUpload(baseConfig, { submitEvent: "save" }) + const input = get(upload.inputEl)! + const clickSpy = vi.spyOn(input, "click") + upload.showFilePicker() + expect(clickSpy).toHaveBeenCalledTimes(1) + }) +}) + +// --------------------------------------------------------------------------- +// addFiles() +// --------------------------------------------------------------------------- + +describe("addFiles()", () => { + // jsdom does not support assigning arbitrary FileList to input.files. + // We override the files property on the specific input element to make it + // writable before calling addFiles(), then verify the change event fires. + + it("accepts an array of File objects and dispatches change event", () => { + vi.useFakeTimers() + try { + const upload = useLiveUpload(baseConfig, { submitEvent: "save" }) + const input = get(upload.inputEl)! + // Make files writable on this instance so jsdom doesn't throw. + Object.defineProperty(input, "files", { writable: true, configurable: true, value: null }) + const dispatchSpy = vi.spyOn(input, "dispatchEvent") + const file = new File(["content"], "test.txt", { type: "text/plain" }) + upload.addFiles([file]) + vi.runAllTimers() + expect(dispatchSpy).toHaveBeenCalledWith( + expect.objectContaining({ type: "change", bubbles: true }) + ) + } finally { + vi.useRealTimers() + } + }) + + it("accepts a DataTransfer object and dispatches change event", () => { + vi.useFakeTimers() + try { + const upload = useLiveUpload(baseConfig, { submitEvent: "save" }) + const input = get(upload.inputEl)! + Object.defineProperty(input, "files", { writable: true, configurable: true, value: null }) + const dispatchSpy = vi.spyOn(input, "dispatchEvent") + const dt = new DataTransfer() + dt.items.add(new File(["content"], "file.png", { type: "image/png" })) + upload.addFiles(dt) + vi.runAllTimers() + expect(dispatchSpy).toHaveBeenCalledWith( + expect.objectContaining({ type: "change", bubbles: true }) + ) + } finally { + vi.useRealTimers() + } + }) + + it("does NOT dispatch change event when DataTransfer is unavailable", () => { + vi.stubGlobal("DataTransfer", undefined) + vi.useFakeTimers() + try { + const upload = useLiveUpload(baseConfig, { submitEvent: "save" }) + const input = get(upload.inputEl)! + const dispatchSpy = vi.spyOn(input, "dispatchEvent") + upload.addFiles([new File(["content"], "test.txt")]) + vi.runAllTimers() + expect(dispatchSpy).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + vi.unstubAllGlobals() + } + }) +}) + +// --------------------------------------------------------------------------- +// submit() +// --------------------------------------------------------------------------- + +describe("submit()", () => { + it("dispatches submit event on the form", () => { + const upload = useLiveUpload(baseConfig, { submitEvent: "save" }) + const input = get(upload.inputEl)! + const form = input.form! + const dispatchSpy = vi.spyOn(form, "dispatchEvent") + upload.submit() + expect(dispatchSpy).toHaveBeenCalledWith( + expect.objectContaining({ type: "submit", bubbles: true }) + ) + }) +}) + +// --------------------------------------------------------------------------- +// cancel() +// --------------------------------------------------------------------------- + +describe("cancel()", () => { + it("cancel(ref) calls pushEvent with cancel-upload and the ref", () => { + const upload = useLiveUpload(baseConfig, { submitEvent: "save" }) + upload.cancel("e1") + expect(mockPushEvent).toHaveBeenCalledWith("cancel-upload", { ref: "e1" }) + }) + + it("cancel() without args cancels all current entries", () => { + const upload = useLiveUpload(baseConfig, { submitEvent: "save" }) + upload.sync({ + ...baseConfig, + entries: [makeEntry({ ref: "e1" }), makeEntry({ ref: "e2" })], + }) + upload.cancel() + expect(mockPushEvent).toHaveBeenCalledTimes(2) + expect(mockPushEvent).toHaveBeenCalledWith("cancel-upload", { ref: "e1" }) + expect(mockPushEvent).toHaveBeenCalledWith("cancel-upload", { ref: "e2" }) + }) + + it("cancel() with no entries makes no pushEvent calls", () => { + const upload = useLiveUpload(baseConfig, { submitEvent: "save" }) + upload.cancel() + expect(mockPushEvent).not.toHaveBeenCalled() + }) +}) + +// --------------------------------------------------------------------------- +// clear() +// --------------------------------------------------------------------------- + +describe("clear()", () => { + it("resets hidden input value", () => { + const upload = useLiveUpload(baseConfig, { submitEvent: "save" }) + const input = get(upload.inputEl)! + // Simulate a value being present. + Object.defineProperty(input, "value", { writable: true, value: "C:\\fakepath\\file.txt" }) + upload.clear() + expect(input.value).toBe("") + }) +}) + +// --------------------------------------------------------------------------- +// DOM cleanup on destroy +// --------------------------------------------------------------------------- + +describe("DOM cleanup", () => { + it("removes form from DOM when cleanup is called", () => { + useLiveUpload(baseConfig, { submitEvent: "save" }) + expect(mockEl.querySelector("form")).not.toBeNull() + + // Run the cleanup function returned by onMount. + const cleanup = (vi.mocked(onMount) as any).__lastCleanup + expect(typeof cleanup).toBe("function") + cleanup() + + expect(mockEl.querySelector("form")).toBeNull() + }) + + it("sets inputEl to null on cleanup", () => { + const upload = useLiveUpload(baseConfig, { submitEvent: "save" }) + expect(get(upload.inputEl)).not.toBeNull() + + const cleanup = (vi.mocked(onMount) as any).__lastCleanup + cleanup() + + expect(get(upload.inputEl)).toBeNull() + }) +}) + +// --------------------------------------------------------------------------- +// Graceful degradation (no LiveSvelte context) +// --------------------------------------------------------------------------- + +describe("graceful degradation without LiveSvelte context", () => { + it("initialises without throwing when getContext returns null", () => { + vi.mocked(getContext).mockReturnValue(null) + expect(() => useLiveUpload(baseConfig, { submitEvent: "save" })).not.toThrow() + }) + + it("does not append form to any element when live context is absent", () => { + vi.mocked(getContext).mockReturnValue(null) + useLiveUpload(baseConfig, { submitEvent: "save" }) + // mockEl should have no children since liveCtx is null. + expect(mockEl.querySelector("form")).toBeNull() + }) + + it("cancel() silently does nothing when context is absent", () => { + vi.mocked(getContext).mockReturnValue(null) + const upload = useLiveUpload(baseConfig, { submitEvent: "save" }) + expect(() => upload.cancel("e1")).not.toThrow() + expect(mockPushEvent).not.toHaveBeenCalled() + }) + + it("stores still work without live context", () => { + vi.mocked(getContext).mockReturnValue(null) + const upload = useLiveUpload(baseConfig, { submitEvent: "save" }) + expect(get(upload.entries)).toEqual([]) + expect(get(upload.progress)).toBe(0) + expect(get(upload.valid)).toBe(true) + upload.sync({ ...baseConfig, entries: [makeEntry()] }) + expect(get(upload.entries)).toHaveLength(1) + }) +}) diff --git a/assets/js/live_svelte/useLiveUpload.ts b/assets/js/live_svelte/useLiveUpload.ts new file mode 100644 index 0000000..6cdb872 --- /dev/null +++ b/assets/js/live_svelte/useLiveUpload.ts @@ -0,0 +1,289 @@ +/** + * useLiveUpload — Svelte composable for Phoenix LiveView file uploads. + * + * Binds to a Phoenix.LiveView.UploadConfig encoded by LiveSvelte.Encoder, + * managing a hidden
/ with the required Phoenix upload + * attributes, and exposing reactive stores for entries, progress, and validity. + * + * Usage: + * ```svelte + * + * + * + * {#each $upload.entries as entry (entry.ref)} + *
{entry.client_name} — {entry.progress}%
+ * {/each} + * ``` + */ + +import { writable, derived, get, type Readable, type Writable } from "svelte/store" +import { onMount } from "svelte" +import { useLiveSvelte } from "./composables" + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/** + * Shape of a single upload entry from Phoenix.LiveView.UploadEntry, + * encoded by LiveSvelte.Encoder. + */ +export interface UploadEntry { + /** Phoenix upload ref for this entry (e.g. "phx-ref-0"). */ + ref: string + /** Original filename from the client. */ + client_name: string + /** File size in bytes. */ + client_size: number + /** MIME type. */ + client_type: string + /** Upload progress 0–100. */ + progress: number + /** Whether the upload has completed. */ + done: boolean + /** Whether the entry passes accept/size validations. */ + valid: boolean + /** Whether Phoenix has acknowledged (preflighted) this entry. */ + preflighted: boolean + /** Entry-specific validation error messages. */ + errors: string[] +} + +/** + * Shape of a Phoenix.LiveView.UploadConfig encoded by LiveSvelte.Encoder. + * Pass `@uploads.name` as a prop from the LiveView. + */ +export interface UploadConfig { + /** Phoenix upload ref (e.g. "phx-abc123"). */ + ref: string + /** Upload name matching `allow_upload(:name, ...)` in the LiveView. */ + name: string + /** Accepted file types (e.g. ".jpg,.png") or false for any. */ + accept: string | false + /** Maximum number of concurrent uploads. */ + max_entries: number + /** When true, uploads begin as soon as files are selected. */ + auto_upload: boolean + /** Current upload entries. */ + entries: UploadEntry[] + /** Top-level upload config errors. */ + errors: { ref: string; error: string }[] +} + +/** Options for `useLiveUpload`. */ +export interface UploadOptions { + /** Server event name for Phoenix phx-change (validation). */ + changeEvent?: string + /** Server event name for Phoenix phx-submit (required). */ + submitEvent: string +} + +/** Return value of `useLiveUpload`. */ +export interface UseLiveUploadReturn { + /** Reactive list of current upload entries from the server. */ + entries: Readable + /** Overall upload progress 0–100 averaged across all entries. */ + progress: Readable + /** True when the upload config has no top-level errors. */ + valid: Readable + /** The underlying hidden `` element store. */ + inputEl: Readable + /** Opens the native file-picker dialog. */ + showFilePicker(): void + /** Enqueue files from an array or DataTransfer (for drag-drop). */ + addFiles(files: File[] | DataTransfer): void + /** Dispatch a form submit event to trigger Phoenix upload (manual upload). */ + submit(): void + /** Cancel a specific entry by ref, or all entries when omitted. */ + cancel(ref?: string): void + /** Reset the hidden input value to clear the file queue. */ + clear(): void + /** + * Merge an updated UploadConfig from the server into the composable. + * Call from a Svelte `$effect(() => { upload.sync(props.uploads.avatar) })`. + */ + sync(newConfig: UploadConfig): void +} + +// --------------------------------------------------------------------------- +// Implementation +// --------------------------------------------------------------------------- + +export function useLiveUpload( + uploadConfig: UploadConfig, + options: UploadOptions +): UseLiveUploadReturn { + // Graceful degradation: 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. + } + + // Core reactive store for the upload config. + const configStore: Writable = writable(uploadConfig) + + // Reactive hidden input element store. + const inputElStore: Writable = writable(null) + + // Derived stores. + const entries: Readable = derived(configStore, ($config) => $config.entries ?? []) + + const progress: Readable = derived(entries, ($entries) => { + if ($entries.length === 0) return 0 + const total = $entries.reduce((sum, entry) => sum + (entry.progress ?? 0), 0) + return Math.round(total / $entries.length) + }) + + const valid: Readable = derived( + configStore, + ($config) => ($config.errors ?? []).length === 0 + ) + + // DOM setup: create the hidden form+input with Phoenix upload attributes. + onMount(() => { + const config = get(configStore) + + // Outer form carries phx-change / phx-submit so Phoenix hooks it. + const form = document.createElement("form") + if (options.changeEvent) form.setAttribute("phx-change", options.changeEvent) + form.setAttribute("phx-submit", options.submitEvent) + form.style.display = "none" + + const input = document.createElement("input") + input.type = "file" + input.id = config.ref + input.name = config.name + + // REQUIRED Phoenix LiveFileUpload hook attributes. + input.setAttribute("data-phx-hook", "Phoenix.LiveFileUpload") + input.setAttribute("data-phx-update", "ignore") + input.setAttribute("data-phx-upload-ref", config.ref) + + // Optional attributes derived from the upload config. + if (config.accept && typeof config.accept === "string") { + input.accept = config.accept + } + if (config.auto_upload) { + input.setAttribute("data-phx-auto-upload", "true") + } + if (config.max_entries > 1) { + input.multiple = true + } + + form.appendChild(input) + + // Append to the LiveView element so Phoenix can find the input. + const liveEl = (liveCtx?.live as { el?: Element } | null)?.el + if (liveEl) { + liveEl.appendChild(form) + } + + inputElStore.set(input) + + // Subscribe to configStore to keep Phoenix ref attributes up-to-date. + const unsub = configStore.subscribe(($config) => { + const joinRefs = (es: UploadEntry[]) => es.map((e) => e.ref).join(",") + input.setAttribute("data-phx-active-refs", joinRefs($config.entries)) + input.setAttribute("data-phx-done-refs", joinRefs($config.entries.filter((e) => e.done))) + input.setAttribute( + "data-phx-preflighted-refs", + joinRefs($config.entries.filter((e) => e.preflighted)) + ) + }) + + // Cleanup: unsubscribe, remove the form, and clear the store. + return () => { + unsub() + form.remove() + inputElStore.set(null) + } + }) + + // --------------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------------- + + function showFilePicker(): void { + const input = get(inputElStore) + if (input) input.click() + } + + function addFiles(files: File[] | DataTransfer): void { + const input = get(inputElStore) + if (!input) return + + const DataTransferClass = + typeof DataTransfer !== "undefined" ? DataTransfer : null + let filesSet = false + if (DataTransferClass && files instanceof DataTransferClass) { + input.files = (files as DataTransfer).files + filesSet = true + } else if (DataTransferClass) { + const dt = new DataTransferClass() + ;(files as File[]).forEach((f) => dt.items.add(f)) + input.files = dt.files + filesSet = true + } + + // Dispatch asynchronously so Phoenix has initialised the upload system. + // Only dispatch if files were actually set — avoids spurious events in + // environments where DataTransfer is unavailable (e.g. SSR/Node.js). + if (filesSet) { + setTimeout(() => { + const current = get(inputElStore) + if (current) current.dispatchEvent(new Event("change", { bubbles: true, cancelable: true })) + }, 0) + } + } + + function submit(): void { + const input = get(inputElStore) + if (input?.form) { + input.form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })) + } + } + + function cancel(ref?: string): void { + if (!liveCtx) return + if (ref !== undefined) { + liveCtx.pushEvent("cancel-upload", { ref }) + } else { + get(entries).forEach((entry) => { + liveCtx!.pushEvent("cancel-upload", { ref: entry.ref }) + }) + } + } + + function clear(): void { + const input = get(inputElStore) + if (input) input.value = "" + } + + function sync(newConfig: UploadConfig): void { + configStore.set(newConfig) + } + + return { + entries, + progress, + valid, + inputEl: { subscribe: inputElStore.subscribe }, + showFilePicker, + addFiles, + submit, + cancel, + clear, + sync, + } +} diff --git a/example_project/assets/svelte/UploadDemo.svelte b/example_project/assets/svelte/UploadDemo.svelte new file mode 100644 index 0000000..9256693 --- /dev/null +++ b/example_project/assets/svelte/UploadDemo.svelte @@ -0,0 +1,153 @@ + + +
+
+ + File Upload + + + +
e.preventDefault()} + ondrop={(e) => { + e.preventDefault() + if (e.dataTransfer) upload.addFiles(e.dataTransfer) + }} + > +

Drag and drop files here, or

+ +

+ Accepts .txt, .pdf, .jpg, .png — max 5 MB each, up to 3 files +

+
+ + + {#if $entries.length > 0} +
    + {#each $entries as entry (entry.ref)} +
  • +
    + + {entry.client_name} + + + {(entry.client_size / 1024).toFixed(1)} KB + +
    +
    + + {entry.progress}% + + {#if !entry.valid} + + {entry.errors[0] ?? "invalid"} + + {/if} + +
    +
  • + {/each} +
+ + + {#if $progress > 0 && $progress < 100} +
+
+
+ {/if} + + +
+ {#if !uploads.test_files.auto_upload} + + {/if} + +
+ {/if} + + + {#if uploaded_files.length > 0} +
+

+ Uploaded +

+
    + {#each uploaded_files as file} +
  • + + {file.name} + + {(file.size / 1024).toFixed(1)} KB + +
  • + {/each} +
+
+ {/if} +
+
diff --git a/example_project/lib/example_web/components/layouts.ex b/example_project/lib/example_web/components/layouts.ex index 5724aa4..16e64e2 100644 --- a/example_project/lib/example_web/components/layouts.ex +++ b/example_project/lib/example_web/components/layouts.ex @@ -61,7 +61,8 @@ defmodule ExampleWeb.Layouts do links: [ %{label: "Form (useLiveForm)", to: ~p"/live-form"}, %{label: "Navigation (useLiveNavigation)", to: ~p"/live-navigation"}, - %{label: "Composition (useLiveSvelte)", to: ~p"/live-composition"} + %{label: "Composition (useLiveSvelte)", to: ~p"/live-composition"}, + %{label: "File Upload (useLiveUpload)", to: ~p"/live-upload"} ] } ] 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 30cec65..13de244 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 @@ -246,6 +246,12 @@ - pushEvent from composed component trees +
  • + + File Upload (useLiveUpload) + + - Upload files using Phoenix LiveView uploads +
  • diff --git a/example_project/lib/example_web/live/live_upload.ex b/example_project/lib/example_web/live/live_upload.ex new file mode 100644 index 0000000..6891482 --- /dev/null +++ b/example_project/lib/example_web/live/live_upload.ex @@ -0,0 +1,56 @@ +defmodule ExampleWeb.LiveUpload do + @moduledoc """ + LiveView demo for the `useLiveUpload()` composable. + Demonstrates file selection, progress tracking, submit, and cancel. + """ + use ExampleWeb, :live_view + + def mount(_params, _session, socket) do + socket = + socket + |> allow_upload(:test_files, + accept: ~w(.txt .pdf .jpg .png), + max_entries: 3, + max_file_size: 5_000_000 + ) + |> assign(uploaded_files: []) + + {:ok, socket} + end + + def handle_event("validate", _params, socket) do + {:noreply, socket} + end + + def handle_event("save", _params, socket) do + uploaded = + consume_uploaded_entries(socket, :test_files, fn _meta, entry -> + {:ok, %{name: entry.client_name, size: entry.client_size}} + end) + + {:noreply, update(socket, :uploaded_files, &(&1 ++ uploaded))} + end + + def handle_event("cancel-upload", %{"ref" => ref}, socket) do + {:noreply, cancel_upload(socket, :test_files, ref)} + end + + def render(assigns) do + ~H""" +
    +

    File Upload (useLiveUpload)

    +

    + File selection, progress tracking, and server-side upload via Phoenix LiveView uploads. +

    + <.svelte + name="UploadDemo" + props={%{ + uploads: %{test_files: @uploads.test_files}, + uploaded_files: @uploaded_files + }} + socket={@socket} + /> +
    + """ + end +end diff --git a/example_project/lib/example_web/router.ex b/example_project/lib/example_web/router.ex index 76b5b58..025b6df 100644 --- a/example_project/lib/example_web/router.ex +++ b/example_project/lib/example_web/router.ex @@ -42,6 +42,7 @@ defmodule ExampleWeb.Router do # Ecto Examples live "/live-notes-otp", LiveNotesOtp live "/live-form", LiveForm + live "/live-upload", LiveUpload live "/live-navigation", LiveNavigation live "/live-navigation/:page", LiveNavigation # not referenced in app.html.heex: diff --git a/example_project/test/example_web/live/live_upload_test.exs b/example_project/test/example_web/live/live_upload_test.exs new file mode 100644 index 0000000..0960f5e --- /dev/null +++ b/example_project/test/example_web/live/live_upload_test.exs @@ -0,0 +1,136 @@ +defmodule ExampleWeb.LiveUploadTest do + @moduledoc """ + E2E tests for the LiveUpload LiveView (/live-upload). + Validates useLiveUpload() composable: initial render, file selection, + upload submission, and entry cancellation. + """ + 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 + + # Wait for an element with the given testid to disappear (retries up to 3s). + defp wait_for_gone(session, testid, attempts \\ 30) + + defp wait_for_gone(_session, testid, 0), + do: raise("timeout waiting for [data-testid='#{testid}'] to disappear") + + defp wait_for_gone(session, testid, attempts) do + els = session |> all(Query.css("[data-testid='#{testid}']")) + + if length(els) == 0 do + session + else + :timer.sleep(100) + wait_for_gone(session, testid, attempts - 1) + end + end + + # Create a temp file for upload tests, cleaned up after the test. + defp tmp_upload_file(content \\ "test upload content") do + path = Path.join(System.tmp_dir!(), "live_upload_test_#{System.unique_integer([:positive])}.txt") + File.write!(path, content) + path + end + + # --------------------------------------------------------------------------- + # Tests + # --------------------------------------------------------------------------- + + test "initial upload page renders container and select button with no entries", %{ + session: session + } do + session + |> visit("/live-upload") + |> wait_for("upload-container") + |> assert_has(Query.css("[data-testid='upload-container']")) + |> assert_has(Query.css("[data-testid='pick-files-btn']")) + |> assert_has(Query.css("[data-testid='drop-zone']")) + |> refute_has(Query.css("[data-testid='upload-entry']")) + |> refute_has(Query.css("[data-testid='uploaded-file']")) + end + + test "selecting a file shows it in the entry list", %{session: session} do + path = tmp_upload_file("hello from test") + + on_exit(fn -> File.rm(path) end) + + session = + session + |> visit("/live-upload") + |> wait_for("pick-files-btn") + + # The hidden file input is created by useLiveUpload's onMount. + # Use visible: false so Wallaby can locate the hidden input. + session = + session + |> attach_file(Query.css("input[type=file]", visible: false), path: path) + + session + |> wait_for("upload-entry") + |> assert_has(Query.css("[data-testid='upload-entry']")) + |> assert_has(Query.css("[data-testid='entry-name']")) + |> assert_has(Query.css("[data-testid='cancel-entry-btn']")) + end + + test "uploading a file triggers save event and shows it in uploaded list", %{session: session} do + path = tmp_upload_file("upload me please") + + on_exit(fn -> File.rm(path) end) + + session = + session + |> visit("/live-upload") + |> wait_for("pick-files-btn") + |> attach_file(Query.css("input[type=file]", visible: false), path: path) + + session = + session + |> wait_for("upload-submit-btn") + |> click(Query.css("[data-testid='upload-submit-btn']")) + + session + |> wait_for("uploaded-file") + |> assert_has(Query.css("[data-testid='uploaded-file']")) + |> assert_has(Query.css("[data-testid='uploaded-name']")) + end + + test "cancelling an entry removes it from the list", %{session: session} do + path = tmp_upload_file("cancel me") + + on_exit(fn -> File.rm(path) end) + + session = + session + |> visit("/live-upload") + |> wait_for("pick-files-btn") + |> attach_file(Query.css("input[type=file]", visible: false), path: path) + + session = + session + |> wait_for("cancel-entry-btn") + |> click(Query.css("[data-testid='cancel-entry-btn']")) + + session + |> wait_for_gone("upload-entry") + |> refute_has(Query.css("[data-testid='upload-entry']")) + end +end