Skip to content

Instantly share code, notes, and snippets.

@guilherme6191
Last active February 13, 2024 19:22
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save guilherme6191/4ede3354d9b2b00585c3d85af7216574 to your computer and use it in GitHub Desktop.
Save guilherme6191/4ede3354d9b2b00585c3d85af7216574 to your computer and use it in GitHub Desktop.
Progressive enhanced form example with Remix, tailwind, conform and zod. + File field.
import {
conform,
list,
useFieldList,
useFieldset,
useForm,
type FieldConfig,
} from '@conform-to/react'
import { getFieldsetConstraint, parse } from '@conform-to/zod'
import {
unstable_createMemoryUploadHandler as createMemoryUploadHandler,
json,
unstable_parseMultipartFormData as parseMultipartFormData,
redirect,
type DataFunctionArgs,
} from '@remix-run/node'
import { Form, useActionData, useLoaderData } from '@remix-run/react'
import { useRef, useState } from 'react'
import { z } from 'zod'
import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx'
import { floatingToolbarClassName } from '#app/components/floating-toolbar.tsx'
import { Button } from '#app/components/ui/button.tsx'
import { Input } from '#app/components/ui/input.tsx'
import { Label } from '#app/components/ui/label.tsx'
import { StatusButton } from '#app/components/ui/status-button.tsx'
import { Textarea } from '#app/components/ui/textarea.tsx'
import { db, updateNote } from '#app/utils/db.server.ts'
import { cn, invariantResponse, useIsSubmitting } from '#app/utils/misc.tsx'
export async function loader({ params }: DataFunctionArgs) {
const note = db.note.findFirst({
where: {
id: {
equals: params.noteId,
},
},
})
if (!note) {
throw new Response('Note not found', { status: 404 })
}
return json({
note: {
title: note.title,
content: note.content,
images: note.images.map(i => ({ id: i.id, altText: i.altText })),
},
})
}
const titleMaxLength = 100
const contentMaxLength = 10000
const MAX_UPLOAD_SIZE = 1024 * 1024 * 3 // 3MB
const ImageFieldsetSchema = z.object({
id: z.string().optional(),
file: z
.instanceof(File)
.refine(file => {
return file.size <= MAX_UPLOAD_SIZE
}, 'File size must be less than 3MB')
.optional(),
altText: z.string().optional(),
})
const NoteEditorSchema = z.object({
title: z.string().max(titleMaxLength),
content: z.string().max(contentMaxLength),
images: z.array(ImageFieldsetSchema),
})
export async function action({ request, params }: DataFunctionArgs) {
invariantResponse(params.noteId, 'noteId param is required')
const formData = await parseMultipartFormData(
request,
createMemoryUploadHandler({ maxPartSize: MAX_UPLOAD_SIZE }),
)
const submission = parse(formData, {
schema: NoteEditorSchema,
})
if (submission.intent !== 'submit') {
return json({ status: 'idle', submission } as const)
}
if (!submission.value) {
return json({ status: 'error', submission } as const, {
status: 400,
})
}
const { title, content, images } = submission.value
await updateNote({ id: params.noteId, title, content, images })
return redirect(`/users/${params.username}/notes/${params.noteId}`)
}
function ErrorList({
id,
errors,
}: {
id?: string
errors?: Array<string> | null
}) {
return errors?.length ? (
<ul id={id} className="flex flex-col gap-1">
{errors.map((error, i) => (
<li key={i} className="text-[10px] text-foreground-destructive">
{error}
</li>
))}
</ul>
) : null
}
export default function NoteEdit() {
const data = useLoaderData<typeof loader>()
const actionData = useActionData<typeof action>()
const isSubmitting = useIsSubmitting()
const [form, fields] = useForm({
id: 'note-editor',
constraint: getFieldsetConstraint(NoteEditorSchema),
lastSubmission: actionData?.submission,
onValidate({ formData }) {
return parse(formData, { schema: NoteEditorSchema })
},
defaultValue: {
title: data.note.title,
content: data.note.content,
images: data.note.images.length ? data.note.images : [{}],
},
})
const imageList = useFieldList(form.ref, fields.images)
return (
<div className="absolute inset-0">
<Form
method="post"
className="flex h-full flex-col gap-y-4 overflow-y-auto overflow-x-hidden px-10 pb-28 pt-12"
{...form.props}
encType="multipart/form-data"
>
<button type="submit" className="hidden" />
<div className="flex flex-col gap-1">
<div>
<Label htmlFor={fields.title.id}>Title</Label>
<Input autoFocus {...conform.input(fields.title)} />
<div className="min-h-[32px] px-4 pb-3 pt-1">
<ErrorList
id={fields.title.errorId}
errors={fields.title.errors}
/>
</div>
</div>
<div>
<Label htmlFor={fields.content.id}>Content</Label>
<Textarea {...conform.textarea(fields.content)} />
<div className="min-h-[32px] px-4 pb-3 pt-1">
<ErrorList
id={fields.content.errorId}
errors={fields.content.errors}
/>
</div>
</div>
<div>
<Label>Images</Label>
<ul className="flex flex-col gap-4">
{imageList.map((image, index) => (
<li
key={image.key}
className="relative border-b-2 border-muted-foreground"
>
<Button
className="text-foreground-destructive absolute right-0 top-0"
{...list.remove(fields.images.name, { index })}
>
<span className="sr-only">Delete image</span>
<span aria-hidden="true"> ❌</span>
</Button>
<ImageChooser config={image} />
</li>
))}
</ul>
</div>
<Button {...list.insert(fields.images.name, { defaultValue: {} })}>
<span className="sr-only">Add image</span>
<span aria-hidden="true">➕ Image</span>
</Button>
</div>
<ErrorList id={form.errorId} errors={form.errors} />
</Form>
<div className={floatingToolbarClassName}>
<Button form={form.id} variant="destructive" type="reset">
Reset
</Button>
<StatusButton
form={form.id}
type="submit"
disabled={isSubmitting}
status={isSubmitting ? 'pending' : 'idle'}
>
Submit
</StatusButton>
</div>
</div>
)
}
function ImageChooser({
config,
}: {
config: FieldConfig<z.infer<typeof ImageFieldsetSchema>>
}) {
const ref = useRef<HTMLFieldSetElement>(null)
const fields = useFieldset(ref, config)
const existingImage = Boolean(fields.id.defaultValue)
const [previewImage, setPreviewImage] = useState<string | null>(
existingImage ? `/resources/images/${fields.id.defaultValue}` : null,
)
const [altText, setAltText] = useState(fields.altText.defaultValue ?? '')
return (
<fieldset ref={ref} {...conform.fieldset(config)}>
<div className="flex gap-3">
<div className="w-32">
<div className="relative h-32 w-32">
<label
htmlFor={fields.file.id}
className={cn('group absolute h-32 w-32 rounded-lg', {
'bg-accent opacity-40 focus-within:opacity-100 hover:opacity-100':
!previewImage,
'cursor-pointer focus-within:ring-4': !existingImage,
})}
>
{previewImage ? (
<div className="relative">
<img
src={previewImage}
alt={altText ?? ''}
className="h-32 w-32 rounded-lg object-cover"
/>
{existingImage ? null : (
<div className="pointer-events-none absolute -right-0.5 -top-0.5 rotate-12 rounded-sm bg-secondary px-2 py-1 text-xs text-secondary-foreground shadow-md">
new
</div>
)}
</div>
) : (
<div className="flex h-32 w-32 items-center justify-center rounded-lg border border-muted-foreground text-4xl text-muted-foreground">
</div>
)}
{existingImage ? (
<input
{...conform.input(fields.id, {
type: 'hidden',
})}
/>
) : null}
<input
aria-label="Image"
className="absolute left-0 top-0 z-0 h-32 w-32 cursor-pointer opacity-0"
onChange={event => {
const file = event.target.files?.[0]
if (file) {
const reader = new FileReader()
reader.onloadend = () => {
setPreviewImage(reader.result as string)
}
reader.readAsDataURL(file)
} else {
setPreviewImage(null)
}
}}
accept="image/*"
{...conform.input(fields.file, {
type: 'file',
})}
/>
</label>
</div>
<div className="min-h-[32px] px-4 pb-3 pt-1">
<ErrorList id={fields.file.errorId} errors={fields.file.errors} />
</div>
</div>
<div className="flex-1">
<Label htmlFor={fields.altText.id}>Alt Text</Label>
<Textarea
onChange={e => setAltText(e.currentTarget.value)}
{...conform.textarea(fields.altText)}
/>
<div className="min-h-[32px] px-4 pb-3 pt-1">
<ErrorList
id={fields.altText.errorId}
errors={fields.altText.errors}
/>
</div>
</div>
</div>
<div className="min-h-[32px] px-4 pb-3 pt-1">
<ErrorList id={config.errorId} errors={config.errors} />
</div>
</fieldset>
)
}
export function ErrorBoundary() {
return (
<GeneralErrorBoundary
statusHandlers={{
404: ({ params }) => (
<p>No note with the id "{params.noteId}" exists</p>
),
}}
/>
)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment