mirror of
https://github.com/woutdp/live_svelte
synced 2026-05-24 09:28:21 +00:00
chore: added file upload
This commit is contained in:
parent
ca4353a562
commit
fa56ed9790
10 changed files with 1278 additions and 1 deletions
|
|
@ -15,3 +15,5 @@ export type {
|
|||
FormFieldArray,
|
||||
UseLiveFormReturn,
|
||||
} from "./useLiveForm";
|
||||
export { useLiveUpload } from "./useLiveUpload";
|
||||
export type { UploadEntry, UploadConfig, UploadOptions, UseLiveUploadReturn } from "./useLiveUpload";
|
||||
|
|
|
|||
106
assets/js/live_svelte/types.d.ts
vendored
106
assets/js/live_svelte/types.d.ts
vendored
|
|
@ -253,3 +253,109 @@ export declare function useLiveForm<T extends object>(
|
|||
options?: FormOptions
|
||||
): UseLiveFormReturn<T>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<UploadEntry[]>;
|
||||
/** Overall upload progress 0–100 averaged across all entries. */
|
||||
progress: Readable<number>;
|
||||
/** True when the upload config has no top-level errors. */
|
||||
valid: Readable<boolean>;
|
||||
/** The underlying hidden `<input type="file">` element store. */
|
||||
inputEl: Readable<HTMLInputElement | null>;
|
||||
/** 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
|
||||
* <script lang="ts">
|
||||
* import { useLiveUpload } from "live_svelte"
|
||||
* let { uploads } = $props()
|
||||
* const upload = useLiveUpload(uploads.avatar, { changeEvent: "validate", submitEvent: "save" })
|
||||
* $effect(() => { upload.sync(uploads.avatar) })
|
||||
* </script>
|
||||
* <button onclick={() => upload.showFilePicker()}>Select Files</button>
|
||||
* {#each $upload.entries as entry}{entry.client_name}{/each}
|
||||
* ```
|
||||
*/
|
||||
export declare function useLiveUpload(
|
||||
uploadConfig: UploadConfig,
|
||||
options: UploadOptions
|
||||
): UseLiveUploadReturn;
|
||||
|
||||
|
|
|
|||
527
assets/js/live_svelte/useLiveUpload.test.ts
Normal file
527
assets/js/live_svelte/useLiveUpload.test.ts
Normal file
|
|
@ -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> = {}): 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)
|
||||
})
|
||||
})
|
||||
289
assets/js/live_svelte/useLiveUpload.ts
Normal file
289
assets/js/live_svelte/useLiveUpload.ts
Normal file
|
|
@ -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 <form>/<input type=file> with the required Phoenix upload
|
||||
* attributes, and exposing reactive stores for entries, progress, and validity.
|
||||
*
|
||||
* Usage:
|
||||
* ```svelte
|
||||
* <script lang="ts">
|
||||
* import { useLiveUpload } from "live_svelte"
|
||||
* import type { UploadConfig } from "live_svelte"
|
||||
*
|
||||
* interface Props { uploads: { avatar: UploadConfig }; uploaded_files: { name: string }[] }
|
||||
* let { uploads, uploaded_files }: Props = $props()
|
||||
*
|
||||
* const upload = useLiveUpload(uploads.avatar, { changeEvent: "validate", submitEvent: "save" })
|
||||
* $effect(() => { upload.sync(uploads.avatar) })
|
||||
* </script>
|
||||
*
|
||||
* <button onclick={() => upload.showFilePicker()}>Select Files</button>
|
||||
* {#each $upload.entries as entry (entry.ref)}
|
||||
* <div>{entry.client_name} — {entry.progress}%</div>
|
||||
* {/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<UploadEntry[]>
|
||||
/** Overall upload progress 0–100 averaged across all entries. */
|
||||
progress: Readable<number>
|
||||
/** True when the upload config has no top-level errors. */
|
||||
valid: Readable<boolean>
|
||||
/** The underlying hidden `<input type="file">` element store. */
|
||||
inputEl: Readable<HTMLInputElement | null>
|
||||
/** 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<typeof useLiveSvelte> | null = null
|
||||
try {
|
||||
liveCtx = useLiveSvelte()
|
||||
} catch {
|
||||
// SSR or test without LiveSvelte context — pushEvent unavailable.
|
||||
}
|
||||
|
||||
// Core reactive store for the upload config.
|
||||
const configStore: Writable<UploadConfig> = writable(uploadConfig)
|
||||
|
||||
// Reactive hidden input element store.
|
||||
const inputElStore: Writable<HTMLInputElement | null> = writable(null)
|
||||
|
||||
// Derived stores.
|
||||
const entries: Readable<UploadEntry[]> = derived(configStore, ($config) => $config.entries ?? [])
|
||||
|
||||
const progress: Readable<number> = 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<boolean> = 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,
|
||||
}
|
||||
}
|
||||
153
example_project/assets/svelte/UploadDemo.svelte
Normal file
153
example_project/assets/svelte/UploadDemo.svelte
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
<script lang="ts">
|
||||
import {useLiveUpload} from "live_svelte"
|
||||
import type {UploadConfig} from "live_svelte"
|
||||
|
||||
interface Props {
|
||||
uploads: {test_files: UploadConfig}
|
||||
uploaded_files: {name: string; size: number}[]
|
||||
}
|
||||
|
||||
let {uploads, uploaded_files}: Props = $props()
|
||||
|
||||
const upload = useLiveUpload(uploads.test_files, {
|
||||
changeEvent: "validate",
|
||||
submitEvent: "save",
|
||||
})
|
||||
|
||||
// Sync server-side upload config updates into the composable.
|
||||
$effect(() => {
|
||||
upload.sync(uploads.test_files)
|
||||
})
|
||||
|
||||
// Reactive store aliases for template use.
|
||||
const entries = upload.entries
|
||||
const progress = upload.progress
|
||||
const valid = upload.valid
|
||||
</script>
|
||||
|
||||
<div
|
||||
data-testid="upload-container"
|
||||
class="w-full max-w-md mx-auto card bg-base-100 shadow-md border border-base-300/50"
|
||||
>
|
||||
<div class="card-body gap-4 p-5">
|
||||
<span class="badge badge-ghost badge-sm font-medium text-base-content/70 w-fit">
|
||||
File Upload
|
||||
</span>
|
||||
|
||||
<!-- File picker and drag-drop zone -->
|
||||
<div
|
||||
data-testid="drop-zone"
|
||||
role="region"
|
||||
aria-label="File drop zone"
|
||||
class="border-2 border-dashed border-base-300 rounded-lg p-6 text-center hover:border-brand/50 transition-colors"
|
||||
ondragover={(e) => e.preventDefault()}
|
||||
ondrop={(e) => {
|
||||
e.preventDefault()
|
||||
if (e.dataTransfer) upload.addFiles(e.dataTransfer)
|
||||
}}
|
||||
>
|
||||
<p class="text-sm text-base-content/50 mb-3">Drag and drop files here, or</p>
|
||||
<button
|
||||
data-testid="pick-files-btn"
|
||||
type="button"
|
||||
class="btn btn-sm bg-brand text-white border-0 hover:opacity-90"
|
||||
onclick={() => upload.showFilePicker()}
|
||||
>
|
||||
Select Files
|
||||
</button>
|
||||
<p class="text-xs text-base-content/40 mt-2">
|
||||
Accepts .txt, .pdf, .jpg, .png — max 5 MB each, up to 3 files
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Entry list -->
|
||||
{#if $entries.length > 0}
|
||||
<ul class="flex flex-col gap-2">
|
||||
{#each $entries as entry (entry.ref)}
|
||||
<li data-testid="upload-entry" class="flex items-center gap-3 text-sm">
|
||||
<div class="flex-1 min-w-0">
|
||||
<span data-testid="entry-name" class="font-medium truncate block">
|
||||
{entry.client_name}
|
||||
</span>
|
||||
<span class="text-xs text-base-content/50">
|
||||
{(entry.client_size / 1024).toFixed(1)} KB
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<span data-testid="entry-progress" class="text-xs w-8 text-right">
|
||||
{entry.progress}%
|
||||
</span>
|
||||
{#if !entry.valid}
|
||||
<span data-testid="entry-error" class="text-xs text-error">
|
||||
{entry.errors[0] ?? "invalid"}
|
||||
</span>
|
||||
{/if}
|
||||
<button
|
||||
data-testid="cancel-entry-btn"
|
||||
type="button"
|
||||
class="btn btn-xs btn-ghost text-error"
|
||||
onclick={() => upload.cancel(entry.ref)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<!-- Overall progress bar -->
|
||||
{#if $progress > 0 && $progress < 100}
|
||||
<div class="w-full bg-base-200 rounded-full h-1.5">
|
||||
<div
|
||||
data-testid="progress-bar"
|
||||
class="bg-brand h-1.5 rounded-full transition-all"
|
||||
style="width: {$progress}%"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex gap-2">
|
||||
{#if !uploads.test_files.auto_upload}
|
||||
<button
|
||||
data-testid="upload-submit-btn"
|
||||
type="button"
|
||||
class="btn btn-sm bg-brand text-white border-0 hover:opacity-90"
|
||||
disabled={!$valid}
|
||||
onclick={() => upload.submit()}
|
||||
>
|
||||
Upload
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
data-testid="cancel-all-btn"
|
||||
type="button"
|
||||
class="btn btn-sm btn-ghost"
|
||||
onclick={() => upload.cancel()}
|
||||
>
|
||||
Cancel All
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Uploaded files list -->
|
||||
{#if uploaded_files.length > 0}
|
||||
<div>
|
||||
<h3 class="text-xs font-medium text-base-content/50 uppercase tracking-wide mb-2">
|
||||
Uploaded
|
||||
</h3>
|
||||
<ul class="flex flex-col gap-1">
|
||||
{#each uploaded_files as file}
|
||||
<li data-testid="uploaded-file" class="flex items-center gap-2 text-sm">
|
||||
<span class="text-success">✓</span>
|
||||
<span data-testid="uploaded-name">{file.name}</span>
|
||||
<span class="text-xs text-base-content/40 ml-auto">
|
||||
{(file.size / 1024).toFixed(1)} KB
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -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"}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -246,6 +246,12 @@
|
|||
</a>
|
||||
<span class="text-base-content/50 text-sm">- pushEvent from composed component trees</span>
|
||||
</li>
|
||||
<li>
|
||||
<a href={~p"/live-upload"} class="link link-primary">
|
||||
File Upload (useLiveUpload)
|
||||
</a>
|
||||
<span class="text-base-content/50 text-sm">- Upload files using Phoenix LiveView uploads</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
56
example_project/lib/example_web/live/live_upload.ex
Normal file
56
example_project/lib/example_web/live/live_upload.ex
Normal file
|
|
@ -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"""
|
||||
<div class="flex flex-col items-center gap-6 p-6">
|
||||
<h2 class="text-center text-2xl font-light my-4">File Upload (useLiveUpload)</h2>
|
||||
<p class="text-sm text-base-content/50 text-center max-w-md">
|
||||
File selection, progress tracking, and server-side upload via Phoenix LiveView uploads.
|
||||
</p>
|
||||
<.svelte
|
||||
name="UploadDemo"
|
||||
props={%{
|
||||
uploads: %{test_files: @uploads.test_files},
|
||||
uploaded_files: @uploaded_files
|
||||
}}
|
||||
socket={@socket}
|
||||
/>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
136
example_project/test/example_web/live/live_upload_test.exs
Normal file
136
example_project/test/example_web/live/live_upload_test.exs
Normal file
|
|
@ -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
|
||||
Loading…
Reference in a new issue