Skip to content

Commit

Permalink
finish final exercise
Browse files Browse the repository at this point in the history
  • Loading branch information
kentcdodds committed Jul 17, 2023
1 parent c550453 commit 3752ca9
Show file tree
Hide file tree
Showing 16 changed files with 328 additions and 66 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,6 @@ export async function action({ request, params }: DataFunctionArgs) {
acceptMultipleErrors: () => true,
})

if (submission.intent !== 'submit') {
return json({ status: 'idle', submission } as const)
}

if (!submission.value) {
return json({ status: 'error', submission } as const, { status: 400 })
}
Expand Down Expand Up @@ -224,6 +220,7 @@ function ImageChooser({
{/* 🐨 if there's an existing image, add a hidden input that with a name "imageId" and the value set to the image's id */}
<input
id="image-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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,6 @@ export async function action({ request, params }: DataFunctionArgs) {
acceptMultipleErrors: () => true,
})

if (submission.intent !== 'submit') {
return json({ status: 'idle', submission } as const)
}

if (!submission.value) {
return json({ status: 'error', submission } as const, { status: 400 })
}
Expand Down Expand Up @@ -237,6 +233,7 @@ function ImageChooser({
) : null}
<input
id="image-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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,6 @@ export async function action({ request, params }: DataFunctionArgs) {
acceptMultipleErrors: () => true,
})

if (submission.intent !== 'submit') {
return json({ status: 'idle', submission } as const)
}

if (!submission.value) {
return json({ status: 'error', submission } as const, { status: 400 })
}
Expand Down Expand Up @@ -237,6 +233,7 @@ function ImageChooser({
) : null}
<input
id="image-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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,6 @@ export async function action({ request, params }: DataFunctionArgs) {
acceptMultipleErrors: () => true,
})

if (submission.intent !== 'submit') {
return json({ status: 'idle', submission } as const)
}

if (!submission.value) {
return json({ status: 'error', submission } as const, { status: 400 })
}
Expand Down Expand Up @@ -236,6 +232,7 @@ function ImageChooser({
) : null}
<input
id="image-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]
Expand Down
81 changes: 80 additions & 1 deletion exercises/05.complex-structures/01.problem.nested/README.mdx
Original file line number Diff line number Diff line change
@@ -1,3 +1,82 @@
# Nested Object

TODO: write this
So far we've just put the image information as properties on our
`NoteEditorSchema`, but the `id`, `file`, and `altText` fields are really all
just part of a single object: An image. So it would be better to represent this
as a nested set of field properties under the `NoteEditorSchema` under `image`.

However, because forms don't support nested objects, we'll need to use a utility
from Conform to help us out. Here's an example that resembles what you'll be
doing:

```tsx lines=16,22-23
// example inspired from the Conform docs
import {
useForm,
useFieldset,
conform,
type FieldConfig,
} from '@conform-to/react'

function Example() {
const [form, fields] = useForm<Schema>({
// ... config stuff including the schema
})

return (
<form {...form.props}>
<AddressFields config={fields.address} />
</form>
)
}

function AddressFields({ config }: { config: FieldConfig<Address> }) {
const ref = useRef<HTMLFieldSetElement>(null)
const fields = useFieldset(ref, config)
return (
<fieldset ref={ref}>
<input {...conform.input(fields.street)} />
<input {...conform.input(fields.zipcode)} />
<input {...conform.input(fields.city)} />
<input {...conform.input(fields.country)} />
</fieldset>
)
}
```

We'll also get our type by using Zod's inference utility:

```tsx
const RocketSchema = z.object({
// ...
})
type RocketType = z.infer<typeof RocketSchema>

function RocketFields({ config }: { config: FieldConfig<RocketType> }) {
// ...
}
```

So, fundamentally, we want to make this change:

```ts remove=4-6 add=7-11
{
title: string
content: string
imageId: string
file: File
altText: string
image: {
id: string
file: File
altText: string
}
}
```

And we want that hooked up to our form. That should be enough to get you going!

- [📜 `useFieldset`](https://conform.guide/api/react#usefieldset)
- [📜 Conform Complex Structures](https://conform.guide/complex-structures)
- [📜 Zod Type Inference](https://zod.dev/?id=type-inference)
- [📜 React ref](https://react.dev/reference/react/useRef)
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,12 @@ const contentMaxLength = 10000

const MAX_UPLOAD_SIZE = 1024 * 1024 * 3 // 3MB

// 🐨 make a ImageFieldsetSchema that's an object which has id, file, and altText

const NoteEditorSchema = z.object({
title: z.string().min(titleMinLength).max(titleMaxLength),
content: z.string().min(contentMinLength).max(contentMaxLength),
// 🐨 move these three properties to the ImageFieldsetSchema
imageId: z.string().optional(),
file: z
.instanceof(File)
Expand All @@ -58,6 +61,7 @@ const NoteEditorSchema = z.object({
}, 'File size must be less than 3MB')
.optional(),
altText: z.string().optional(),
// 🐨 add an image property that's assigned to the ImageFIeldsetSchema
})

export async function action({ request, params }: DataFunctionArgs) {
Expand All @@ -73,19 +77,17 @@ export async function action({ request, params }: DataFunctionArgs) {
acceptMultipleErrors: () => true,
})

if (submission.intent !== 'submit') {
return json({ status: 'idle', submission } as const)
}

if (!submission.value) {
return json({ status: 'error', submission } as const, { status: 400 })
}
// 🐨 just grab the "image" instead of file, imageId, and altText
const { title, content, file, imageId, altText } = submission.value

await updateNote({
id: params.noteId,
title,
content,
// 🐨 just pass the image in the array instead of constructing an object here
images: [{ file, id: imageId, altText }],
})

Expand Down Expand Up @@ -128,6 +130,9 @@ export default function NoteEdit() {
defaultValue: {
title: data.note.title,
content: data.note.content,
// 🐨 add a default value for the image
// 💰 data.note.images[0]
// you'll be referencing the default values in the component below.
},
})

Expand Down Expand Up @@ -167,6 +172,7 @@ export default function NoteEdit() {
</div>
<div>
<Label>Image</Label>
{/* 🐨 pass the fields.image config instead of the image itself */}
<ImageChooser image={data.note.images[0]} />
</div>
</div>
Expand All @@ -192,20 +198,29 @@ export default function NoteEdit() {
function ImageChooser({
image,
}: {
// 🐨 change this prop to "config" which is Conform FieldConfig of the ImageFieldsetSchema
image?: { id: string; altText?: string | null }
}) {
// 🐨 create a ref for the fieldset
// 🐨 create a conform fields object with useFieldset

// 🐨 the existingImage should now be based on whether fields.id.defaultValue is set
const existingImage = Boolean(image)
const [previewImage, setPreviewImage] = useState<string | null>(
// 🐨 this should now reference fields.id.defaultValue
existingImage ? `/resources/images/${image?.id}` : null,
)
// 🐨 this should now reference fields.altText.defaultValue
const [altText, setAltText] = useState(image?.altText ?? '')

return (
// 🐨 pass the ref prop to fieldset
<fieldset>
<div className="flex gap-3">
<div className="w-32">
<div className="relative h-32 w-32">
<label
// 🐨 update this htmlFor to reference fields.id.id
htmlFor="image-input"
className={cn('group absolute h-32 w-32 rounded-lg', {
'bg-accent opacity-40 focus-within:opacity-100 hover:opacity-100':
Expand All @@ -232,10 +247,15 @@ function ImageChooser({
</div>
)}
{existingImage ? (
// 🐨 update this to use the conform.input helper on
// fields.image.id (make sure it stays hidden though)
// 💰 make sure to use the ariaAttributes option
<input name="imageId" type="hidden" value={image?.id} />
) : null}
<input
// 💣 remove this id
id="image-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]
Expand All @@ -250,20 +270,27 @@ function ImageChooser({
setPreviewImage(null)
}
}}
// 💣 remove the name and type props
name="file"
type="file"
accept="image/*"
// 🐨 add the props from conform.input with the fields.file
// 💰 make sure to use the ariaAttributes option
/>
</label>
</div>
</div>
<div className="flex-1">
{/* 🐨 update this htmlFor to reference fields.altText.id */}
<Label htmlFor="alt-text">Alt Text</Label>
<Textarea
// 💣 remove the id, name, and defaultValue
id="alt-text"
name="altText"
defaultValue={altText}
onChange={e => setAltText(e.currentTarget.value)}
// 🐨 add the props from conform.textarea with the fields.altText
// 💰 make sure to use the ariaAttributes option
/>
</div>
</div>
Expand Down
12 changes: 11 additions & 1 deletion exercises/05.complex-structures/01.solution.nested/README.mdx
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
# Nested Object

TODO: write this
👨‍💼 Great! Even though forms don't technically support nested objects (again,
nested forms aren't allowed), Conform allows us to simulate that. This makes it
much easier for us to maintain a sensible data structure for our forms.

But did you notice that our database allows notes to have more than a single
image? Sure would be nice if we could add more than one image to a note, right?
Let's do that next!

💯 Now that our form is wired up with conform, we can render the errors for
these fields. Feel free to try that if you've got extra time (🧝‍♂️ Kellie will
do it for you if you don't).
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,6 @@ export async function action({ request, params }: DataFunctionArgs) {
acceptMultipleErrors: () => true,
})

if (submission.intent !== 'submit') {
return json({ status: 'idle', submission } as const)
}

if (!submission.value) {
return json({ status: 'error', submission } as const, { status: 400 })
}
Expand Down Expand Up @@ -243,6 +239,7 @@ function ImageChooser({
/>
) : 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]
Expand Down
57 changes: 56 additions & 1 deletion exercises/05.complex-structures/02.problem.lists/README.mdx
Original file line number Diff line number Diff line change
@@ -1,3 +1,58 @@
# Field Lists

TODO: write this
👨‍💼 We want users to be able to upload multiple images to the notes. So we're
going to need to adjust our Zod config to allow for an array:

```tsx
const RocketSchema = z.object({})
const RocketsSchema = z.array(RocketSchema)
```

This is easy enough, but remember that Forms don't support arrays. So we need to
reach for Conform's utilities to make this a possibility. Luckily, Conform has
a handy utility called
[`useFieldList`](https://conform.guide/api/react#usefieldlist) which allows you
to represent an array of fields in a Form. Here's the example from the Conform
docs:

```tsx
import { useForm, useFieldList, list } from '@conform-to/react'

/**
* Consider the schema as follow:
*/
type Schema = {
items: string[]
}

function Example() {
const [form, { items }] = useForm<Schema>()
const itemsList = useFieldList(form.ref, items)

return (
<fieldset ref={ref}>
{itemsList.map((item, index) => (
<div key={item.key}>
{/* Setup an input per item */}
<input name={item.name} />

{/* Error of each item */}
<span>{item.error}</span>

{/* Setup a delete button (Note: It is `items` not `item`) */}
<button {...list.remove(items.name, { index })}>Delete</button>
</div>
))}

{/* Setup a button that can append a new row with optional default value */}
<button {...list.append(items.name, { defaultValue: '' })}>add</button>
</fieldset>
)
}
```

You'll notice the `list` utilities in there. We'll get to that in the next step.
For now, just focus on getting the `useFieldList` working for our images.

- [📜 Zod arrays](https://zod.dev/?id=arrays)
- [📜 Conform `useFieldList`](https://conform.guide/api/react#usefieldlist)
Loading

0 comments on commit 3752ca9

Please sign in to comment.