Skip to content

Instantly share code, notes, and snippets.

@schpet
Last active December 16, 2021 00:39
Show Gist options
  • Save schpet/96fb6f97c5780cc3f6f8dbf34ddd290d to your computer and use it in GitHub Desktop.
Save schpet/96fb6f97c5780cc3f6f8dbf34ddd290d to your computer and use it in GitHub Desktop.
React component for adding and removing files from Active Record's `has_many_attached`. assumes imgix is used.
= form_for @thing do |f|
%label{ for: "docs" }
Documents
-# name prop here should be whatever the built in input type=file would have as a name attribute
-# documents here assumes your model has `has_many_attached :documents`
= react_component "FancyFileInput",
direct_upload_url: rails_direct_uploads_url,
name: "thing[documents][]",
imgix_source: ::Imgix::Rails.config.imgix.fetch(:source),
attachments: f.object.documents.attachments.map {|a| {id: a.id, signed_id: a.signed_id, imgix_url: ix_image_url(a.key) } },
file_input_id: "docs"
import { DirectUpload } from "@rails/activestorage"
import React, { useRef, useState } from "react"
import Imgix, { ImgixProvider } from "react-imgix"
import { withBasics } from "./withBasics"
export interface Props {
name: string
directUploadUrl: string
attachments: Attachment[]
imgixSource: string
fileInputId: string
}
export interface Attachment {
id: string
signedId: string
imgixUrl: string
}
/** ActiveStorage.Blob isn't quite accurate */
export type Blobby = ActiveStorage.Blob & {
id: string
key: string
created_at: Date
service_name: string
}
/**
* intended to be UI element that replaces <input type=file multiple />
* within the context of active record
*/
function FancyFileInput(props: Props) {
const fileRef = useRef<HTMLInputElement>(null)
const [blobs, setBlobs] = useState<Array<Blobby>>([])
const [inputKey, setInputKey] = useState(0)
const [attachments, setAttachments] = useState(props.attachments)
const [loadingCount, setLoadingCount] = useState<number>(0)
const handleChange = () => {
if (!fileRef.current) throw new Error(`expected file ref`)
const files = fileRef.current.files
if (!files) return
for (const file of Array.from(files)) {
const upload = new DirectUpload(file, props.directUploadUrl)
setLoadingCount((prev) => prev + 1)
upload.create((error, blob) => {
setLoadingCount((prev) => prev - 1)
if (error) {
alert(`Error uploading file: ${error}`)
return
}
setBlobs((prev) => [...prev, (blob as unknown) as Blobby])
setInputKey((prev) => prev + 1)
})
}
}
return (
<ImgixProvider domain={props.imgixSource}>
<div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gap: 10,
}}
>
{attachments.map((a) => (
<ImageBlock
key={a.id}
signedId={a.signedId}
src={a.imgixUrl}
onClick={() =>
setAttachments((prev) => prev.filter((p) => p.id !== a.id))
}
name={props.name}
/>
))}
{blobs.map((b) => (
<ImageBlock
key={b.id}
signedId={b.signed_id}
src={b.key}
onClick={() => {
setBlobs((prev) => prev.filter((p) => p.id !== b.id))
}}
name={props.name}
/>
))}
</div>
<input
type="file"
ref={fileRef}
name="ignore"
key={inputKey}
onChange={(e) => {
handleChange()
}}
multiple={true}
id={props.fileInputId}
/>
{loadingCount !== 0 ? (
<div className="spinner-border" role="status">
<span className="sr-only">Loading...</span>
</div>
) : null}
</div>
</ImgixProvider>
)
}
export default withBasics(FancyFileInput)
const ImageBlock = (props: {
name: string
signedId: string
src: string
onClick: () => void
}) => {
return (
<div>
<input type="hidden" name={props.name} value={props.signedId} />
<div className="border">
<Imgix
src={props.src}
sizes="100px"
width={100}
imgixParams={{
ar: "1:1",
fit: "crop",
}}
/>
</div>
<button type="button" onClick={props.onClick} className="btn btn-sm">
&times; &nbsp;
<span className="small">Remove</span>
</button>
</div>
)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment