Overview
File uploads are another common feature of web applications. LiveViewJS provides built in support for file uploads, image previews, upload progress, drag and drop, error handling, and more. Handling file uploads can be intimidating, but LiveViewJS makes it easy.
Learn by Example
We're going to start with a complete example and then walk through the code. The example LiveView allows you to create a new photo album with a name and up to 3 photos.
This example is available as part of the packages/examples directory in the
LiveViewJS repository and runs on both the Express (NodeJS) and Oak (Deno)
servers.
Example LiveView Code
import {
createLiveView,
error_tag,
form_for,
html,
LiveViewChangeset,
live_file_input,
live_img_preview,
mime,
SingleProcessPubSub,
submit,
text_input,
UploadEntry,
} from "liveviewjs";
import { nanoid } from "nanoid";
import { z } from "zod";
import { InMemoryChangesetDB } from "../../datastore/InMemory";
type PhotosContext = {
photoGroups: PhotoGroup[];
changeset: LiveViewChangeset<PhotoGroup>;
};
type PhotosEvents =
| { type: "validate"; name: string }
| { type: "save"; name: string; urls: string[] }
| { type: "cancel"; config_name: string; ref: string };
export const photosLiveView = createLiveView<PhotosContext, PhotosEvents>({
mount: async (socket) => {
if (socket.connected) {
// listen to photos topic
await socket.subscribe(photoGroupTopic);
}
// setup the default context
socket.assign({
photoGroups: photoGroupStore.list(),
changeset: photoGroupStore.changeset(),
});
// configure the upload constraints
socket.allowUpload("photos", {
accept: [".png", ".jpg", ".jpeg", ".gif"], // only allow images
maxEntries: 3,
maxFileSize: 10 * 1024 * 1024, // 10MB
});
},
handleEvent: async (event, socket) => {
switch (event.type) {
case "validate": {
// just validate the changeset
socket.assign({ changeset: photoGroupStore.validate(event) });
break;
}
case "save": {
// first get the completed file uploads and map them to urls
// Note: the files are guaranteed to be completed here because
// save is the event called after all the uploads are complete
const { completed } = await socket.uploadedEntries("photos");
// set the urls on the event (which was not set via the form)
event.urls = completed.map(filename);
// attempt to save the photo
const photoCreate = photoGroupStore.create(event);
if (!photoCreate.valid) {
// if the photo is not valid, assign the changeset and return
// so that the form is re-rendered with the errors
socket.assign({
changeset: photoCreate,
});
return;
}
// Yay! We've successfully saved the photo, so we can consume (i.e., "remove")
// the uploaded entries from the "photos" upload config
await socket.consumeUploadedEntries("photos", async (meta, entry) => {
// we could create thumbnails, scan for viruses, etc.
// but for now move the data from the temp file (meta.path) to a public directory
meta.fileSystem.createOrAppendFile(`./public/${filename(entry)}`, meta.path);
});
// update the context with new photos and clear the form
socket.assign({
photoGroups: photoGroupStore.list(),
changeset: photoGroupStore.changeset(),
});
break;
}
case "cancel": {
const { config_name, ref } = event;
// remove the uploaded entry from the upload config
socket.cancelUpload(config_name, ref);
}
}
},
// Handle broadcast events from the pub/sub subscription for the "photoGroup" topic
handleInfo: (info, socket) => {
const { data } = info;
socket.assign({
photoGroups: [data],
changeset: photoGroupStore.changeset(),
});
},
// Render the view
render: (ctx, meta) => {
const { photoGroups, changeset } = ctx;
const { uploads } = meta;
return html`
<h2>My Photo Groups</h2>
<!-- Render the form -->
${form_for<PhotoGroup>("#", meta.csrfToken, {
id: "photo-form",
phx_change: "validate",
phx_submit: "save",
})}
<!-- photo group name input -->
<div>
Photo Group Name:
${text_input<PhotoGroup>(changeset, "name")}
${error_tag<PhotoGroup>(changeset, "name")}
</div>
<div>
<!-- file input / drag and drop -->
<div phx-drop-target="${uploads.photos.ref}" style="border: 2px dashed #ccc; padding: 10px; margin: 10px 0;">
${live_file_input(uploads.photos)}
or drag and drop files here
</div>
<!-- help text -->
<div style="font-size: 10px; padding-bottom: 3rem">
Add up to ${uploads.photos.maxEntries} photos
(max ${uploads.photos.maxFileSize / (1024 * 1024)} MB each)
</div>
</div>
<!-- any errors from the upload -->
${uploads.photos.errors?.map((error) => html`<p class="invalid-feedback">${error}</p>`)}
<!-- render the preview, progress, and cancel button of the selected files -->
${uploads.photos.entries.map(renderEntry)}
<!-- submit button -->
${submit("Upload", { phx_disable_with: "Saving...", disabled: uploads.photos.errors.length > 0 })}
</form>
<!-- render the photo groups -->
<ul id="photo_groups_list" phx-update="prepend">
${photoGroups.map(renderPhotoGroup)}
</ul>
`;
},
});
// Render a preview of the uploaded file with progress bar and cancel button
function renderEntry(entry: UploadEntry) {
return html`
<div style="display: flex; align-items: center;">
<div style="width: 250px; border: 1px solid black; margin: 2rem 0;">${live_img_preview(entry)}</div>
<div style="display: flex; align-items: center; margin-left: 2rem;">
<progress
style="position: relative; top: 8px; width: 150px; height: 1em;"
value="${entry.progress}"
max="100"></progress>
<span style="margin-left: 1rem;">${entry.progress}%</span>
</div>
<div style="display: flex; align-items: center;">
<a style="padding-left: 2rem;" phx-click="cancel" phx-value-config_name="photos" phx-value-ref="${entry.ref}"
>🗑</a
>
${entry.errors?.map((error) => html`<p style="padding-left: 1rem;" class="invalid-feedback">${error}</p>`)}
</div>
</div>
`;
}
// Render a photo group with a list of photos
function renderPhotoGroup(photoGroup: PhotoGroup) {
return html`<li id="${photoGroup.id}">
${photoGroup.urls.map(
(url, i) => html`
<h3>${photoGroup.name}(${i + 1})</h3>
<img src="${url}" />
`
)}
</li>`;
}
// Define the shape of the Photo type
const PhotoGroupSchema = z.object({
id: z.string().default(nanoid),
name: z.string().min(1).max(100),
urls: z.array(z.string()).min(1).default([]),
});
// Infer the type from the schema
type PhotoGroup = z.infer<typeof PhotoGroupSchema>;
// Pubsub topic for photos
const photoGroupTopic = "photoGroup";
// InMemory DB for photoGroup that publishes changes to the "photos" topic
const photoGroupStore = new InMemoryChangesetDB<PhotoGroup>(PhotoGroupSchema, {
pubSub: new SingleProcessPubSub(),
pubSubTopic: photoGroupTopic,
});
/**
* `filename` maps the upload entry to a filename based on the mime type of the entry
* concatenated with the entry's uuid
*/
function filename(entry: UploadEntry) {
const exts = mime.lookupExtensions(entry.client_type);
const ext = exts.length > 0 ? exts[0] : "bin";
return `${entry.uuid}.${ext}`;
}
Let's review each part in more detail to understand what's going on.
Configure the upload
First, we need to tell LiveView that we want to upload files and we use the socket.allowUpload method in mount to do
so:
mount: (socket) => {
...
// configure the upload constraints
socket.allowUpload("photos", {
accept: [".png", ".jpg", ".jpeg", ".gif"], // only allow images
maxEntries: 3,
maxFileSize: 10 * 1024 * 1024, // 10MB
});
...
}
The allowUpload method takes a config_name and an UploadConfig object. The config_name is used to identify the
upload config elsewhere in the LiveView lifecycle methods. More details on config options.
User Interface
There is a lot going on in our LiveView's render function so let's walk through that.
Setup the Form
As usual, we start by rendering the form with the form_for helper and set the phx-change and phx-submit events to
validate and save respectively.
...
${form_for<PhotoGroup>("#", meta.csrfToken, {
id: "photo-form",
phx_change: "validate",
phx_submit: "save",
})}
...
We will look at the handleEvent method later to see how especially the save event is handled.
File Input and Drag and Drop
Next, we need a place for the user to choose files for upload. We use the live_file_input helper to render the file
input and the phx-drop-target attribute to make the element a drop target for files. The phx-drop-target attribute
takes a ref which is used to identify the upload config in the LiveView lifecycle methods. You'll notice we are
referencing the uploads.photos config we configured in mount earlier.
...
<!-- file input / drag and drop -->
<div phx-drop-target="${uploads.photos.ref}" style="border: 2px dashed #ccc; padding: 10px; margin: 10px 0;">
${live_file_input(uploads.photos)}
or drag and drop files here
</div>
...
🤯 We just added a drag and drop target to our user interface with a single attribute (i.e.
phx-drop-target="${uploads.photos.ref}")! Pretty cool, right!? Thanks Phoenix LiveView team!! 🙌
The live_file_input helper goes beyond just rendering the file input, it also adds some required attributes
to the file input and works with the rest of LiveViewJS to handle uploads. You should always use it rather than
rendering the file input yourself.
Dynamic Help Text
A very nice aspect of having the upload config available in render is it allows us to dynamically render help text
based on the upload config:
...
Add up to ${uploads.photos.maxEntries} photos
(max ${uploads.photos.maxFileSize / (1024 * 1024)} MB each)
...
Show preview, progress, and cancel for entries
When a user selects (or drags and drops) files for upload, the meta.uploads object is automatically updated with those
entries (and any errors for the upload or entries). We can use the upload.entries (and upload.errors) to show the
user what will be uploaded or what errors in their selections.
...
<!-- render the preview, progress, and cancel button of the selected files -->
${uploads.photos.entries.map(renderEntry)}
...
The renderEntry function shows the image preview using live_img_preview and the progress of the upload using a
progress element. We also render a cancel button using the phx-click event to cancel the upload.
live_img_preview helper to render a preview of the imagebefore it is uploaded. Again, pretty amazing that we get an image preview for free! Thanks Phoenix LiveView team!! 🙌
Show errors as well
We configured the file uploads to only allow certain image file types, limited the number of files to 3, and limited the
file size to 10MB. If the user selects files that don't meet these constraints, the uploads object will be updated
with the errors for the given config. We can use the upload.photos.errors to show the user what errors they have made
for the upload config and entry.errors to show the errors for a given entry.
...
<!-- render the errors for the upload config -->
${uploads.photos.errors?.map((error) => html`<p class="invalid-feedback">${error}</p>`)}
...
...
<!-- render the errors for the entry -->
${entry.errors?.map((error) => html`<p class="invalid-feedback">${error}</p>`)}
...
Whew, we've got some pretty amazing functionality in our UI and we haven't even uploaded any files yet! Let's look at the LiveView lifecycle methods to see how we handle the uploads.
handleEvent Cases
handleEvent has two main events that it is handling for us: cancel, and save. Let's look at each of these in turn.
cancel event
A user may want to remove an entry from the setup of files they have selected. Perhaps the file is too large or the
wrong type or they've simply changed their mind. Our renderEntry function renders a cancel button next to each entry
that fires off the cancel event enabling the user to remove the entry from the upload.
...
handleEvent: (event, socket) => {
...
case "cancel": {
const { ref } = event;
// remove the entry from the upload config
socket.cancelUpload("photos", ref);
break;
}
...
}
...
A user can cancel an upload anytime before the socket.consumeUploadedEntries method is called.
save event
The save event is automatically fired when the user submits the form. In the case of file uploads, this event is not
sent to the handleEvent method until after all the files have been fully uploaded.
The upload progress for each entry will automatically be updated and the render method will be executed as
they are uploaded allowing us to show the user the progress of the upload.
Let's look at the save event handler:
...
handleEvent: (event, socket) => {
...
case "save": {
// first get the completed file uploads and map them to urls
// Note: the files are guaranteed to be completed here because
// save is the event called after all the uploads are complete
const { completed } = await socket.uploadedEntries("photos");
// set the urls on the event (which was not set via the form)
event.urls = completed.map(filename);
// attempt to save the photo
const photoCreate = photoGroupStore.create(event);
if (!photoCreate.valid) {
// if the photo is not valid, assign the changeset and return
// so that the form is re-rendered with the errors
socket.assign({
changeset: photoCreate,
});
return;
}
// Yay! We've successfully saved the photo, so we can consume (i.e., "remove")
// the uploaded entries from the "photos" upload config
await socket.consumeUploadedEntries("photos", async (meta, entry) => {
// we could create thumbnails, scan for viruses, etc.
// but for now move the data from the temp file (meta.path) to a public directory
meta.fileSystem.createOrAppendFile(`./public/${filename(entry)}`, meta.path);
});
// update the context with new photos and clear the form
socket.assign({
photoGroups: photoGroupStore.list(),
changeset: photoGroupStore.changeset(),
});
break;
}
...
}
It's pretty well commented but to summarize:
- We get the completed uploads from the
photosupload config. (Note: the files are guaranteed to be completed here becausesaveis the event called only after all the uploads are complete). - We map each entry to a url and add the
urlsto theevent(which will become thephotoGroup). - We attempt to save the
photoGroupand check if the changeset is valid. If not, we return here to show the errors rather thanconsumeUploadedEntries. - If the changeset is valid, we
consumeUploadedEntrieswhich will move the files from the temp directory to the public directory and importantly, remove these files from the upload config. - Finally, We update the
contextand clear the form.
Conclusion
Thanks for sticking with us through that. It was long and detailed and hopefully it was helpful. We think LiveViewJS provides some pretty amazing out of the box support for file uploads.