Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Not able to validate file uploads on the backend, even though it works on the frontend #497

Closed
aokigit opened this issue Mar 29, 2024 · 4 comments
Assignees
Labels
question Further information is requested

Comments

@aokigit
Copy link

aokigit commented Mar 29, 2024

I've been pulling my hair out to get this to work, but I feel like I've hit a wall with this issue I'm not able to solve by myself.
I'm using Nextjs with react-hook-form, valibot and server actions.

Installed dependencies:

"@hookform/resolvers": "^3.3.4",
"next": "^14.1.4",
"react-hook-form": "^7.51.2",
"typescript": "^5.4.3"
"valibot": "^0.30.0"

Issue

I'm trying to implement a basic image file upload, got the frontend validation working but it seems to break on the backend (server action) using the same validation.

When logging the file to be uploaded on the frontend, it correctly logs the avatar file.
Logging avatar inside the server action logs also the exact same file, which looks like this:

File {
  size: 153756,
  type: 'image/jpeg',
  name: 'F-R0rVnaUAAEixq.jpg',
  lastModified: 1711671516468
}

However, when performing the validation inside the server action like this:

const avatar = values.get("avatar");
const result = safeParse(ChangeAvatarFormSchema, avatar, { abortEarly: true });

I receive the following error from valibot:

{
  typed: false,
  success: false,
  output: {},
  issues: [
    {
      reason: 'type',
      context: 'instance',
      expected: 'File',
      received: 'undefined',
      message: 'Avatar is required',
      input: undefined,
      path: [Array],
      issues: undefined,
      lang: undefined,
      abortEarly: true,
      abortPipeEarly: undefined,
      skipPipe: undefined
    }
  ]
}

The file for whatever reason is undefined even though I just logged it right above without issues.

Full code

My form on the frontend:

"use client";

import { valibotResolver } from "@hookform/resolvers/valibot";
import { Controller, useForm } from "react-hook-form";
import { changeAvatar } from "@/lib/actions";
import { ChangeAvatarFormSchema, type ChangeAvatarSchema } from "@/lib/validations";

export default function AvatarForm() {
  const { control, setError, handleSubmit, formState: { errors, isSubmitting } } = useForm<ChangeAvatarSchema>({
    resolver: valibotResolver(ChangeAvatarFormSchema),
    defaultValues: {
      avatar: undefined,
    },
  }); // prettier-ignore

  async function onSubmit(values: ChangeAvatarSchema) {
    const formData = new FormData();
    formData.append("avatar", values.avatar);

    const result = await changeAvatar(formData);

    if (!result.success) {
      return setError("avatar", { message: result.error });
    }

    return;
  }

  return (
    <form className="space-y-6 rounded-xl border border-zinc-800 bg-zinc-900 p-6" onSubmit={handleSubmit(onSubmit)}>
      {errors.root && <div className="rounded-lg border border-red-500/20 bg-red-500/10 p-4 text-xs font-medium text-red-500">{errors.root.message}</div>}
      <div className="space-y-2">
        <label className="block text-sm text-zinc-300" htmlFor="avatar">
          Avatar
        </label>
        <Controller
          control={control}
          name="avatar"
          render={({ field: { onChange, value, ...field } }) => (
            <input
              {...field}
              className="disabled:cursor-not-allowed disabled:opacity-75 sm:text-sm"
              disabled={isSubmitting}
              id="avatar"
              name="avatar"
              type="file"
              onChange={(event) => onChange(event.target.files?.[0] || null)}
            />
          )}
        />
        {errors.avatar && <p className="text-xs text-red-500">{errors.avatar.message}</p>}
      </div>
      <button className="h-10 w-full rounded-lg bg-blue-500 px-3 hover:enabled:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-75" disabled={isSubmitting} type="submit">
        <span className="text-sm font-medium text-white">{isSubmitting ? "Loading" : "Change avatar"}</span>
      </button>
    </form>
  );
}

Server action:

"use server"

import { safeParse } from "valibot";
import { ChangeAvatarFormSchema } from "@/lib/validations";

type ServerActionResult = {
  success: boolean;
  error?: string;
};

export async function changeAvatar(values: FormData): Promise<ServerActionResult> {
  const avatar = values.get("avatar");

  console.log(avatar);

  const result = safeParse(ChangeAvatarFormSchema, avatar, { abortEarly: true });

  console.log(result);

  if (result.success) {
    return { success: true };
  } else {
    return { success: false, error: result.issues[0].message };
  }
}

Validation:

import { Input, instance, maxSize, mimeType, object } from "valibot";

export const ChangeAvatarFormSchema = object({
  avatar: instance(File, "Avatar is required", [
    mimeType(["image/jpeg", "image/png", "image/webp"], "Only JPG, PNG and WEBP files are accepted"),
    maxSize(1 * 1024 * 1024, "Max avatar size is 1 MB"),
  ]),
});

export type ChangeAvatarSchema = Input<typeof ChangeAvatarFormSchema>;
@fabian-hiller
Copy link
Owner

Is values.get("avatar") an object or a file? Because ChangeAvatarFormSchema defines an object which contains a file. Personally I use blob instead of instance because files sent to the server usually arrive as Blob instead of File.

@aokigit
Copy link
Author

aokigit commented Mar 30, 2024

Is values.get("avatar") an object or a file? Because ChangeAvatarFormSchema defines an object which contains a file. Personally I use blob instead of instance because files sent to the server usually arrive as Blob instead of File.

values.get("avatar") is an object that contains the file.
I've tried using object, instance and blob without any luck, every attempt to solve this fails at the server action validation. 😅

@fabian-hiller
Copy link
Owner

If you log values.get("avatar") does it match the schema? I agree that it is wired that the issue has an input of undefined.

@fabian-hiller fabian-hiller self-assigned this Apr 1, 2024
@fabian-hiller fabian-hiller added the question Further information is requested label Apr 1, 2024
@aokigit
Copy link
Author

aokigit commented Apr 6, 2024

I finally managed to solve this. Leaving my code here in case someone has a hard time figuring this out as well.
If this can be simplified or improved, please let me know!

Client form:

"use client";

import { valibotResolver } from "@hookform/resolvers/valibot";
import { useForm } from "react-hook-form";
import { changeAvatar } from "@/lib/actions";
import { changeAvatarSchema, type changeAvatarInput } from "@/lib/validations";

export default function ChangeAvatarForm() {
  const { register, handleSubmit, setValue, formState: { errors, isSubmitting } } = useForm<changeAvatarInput>({
    resolver: valibotResolver(changeAvatarSchema),
    defaultValues: {
      avatar: undefined,
    },
  });

  const onAvatarChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const file = event.target.files && event.target.files.length > 0 ? event.target.files[0] : undefined;

    if (file) {
      setValue("avatar", file);
    }
  };

  async function onSubmit(values: changeAvatarInput) {
    const formData = new FormData();
    formData.append("avatar", values.avatar);

    const response = await changeAvatar(formData);
    return console.log(response);
  }

  return (
    <form className="space-y-6" onSubmit={handleSubmit(onSubmit)}>
      <div className="space-y-2">
        <label htmlFor="avatar" className="block text-sm font-medium text-secondary-300">
          Avatar
        </label>
        <input
          className="file:mr-2 file:inline-flex file:h-8 file:rounded-lg file:border-0 file:bg-secondary-900 file:px-2.5 file:text-xs file:text-secondary-400"
          id="avatar"
          name="avatar"
          type="file"
          onChange={onAvatarChange}
        />
        {errors.avatar && <p className="text-xs text-red-500">{errors.avatar.message}</p>}
      </div>
      <button
        className="inline-flex h-10 w-full items-center justify-center rounded-lg bg-brand-500 px-3.5 text-sm font-medium text-white hover:bg-brand-600"
        type="submit"
      >
        Change avatar
      </button>
    </form>
  );
}

Server action:

"use server";

import { safeParse } from "valibot";
import { changeAvatarSchema } from "@/lib/validations";

type ServerActionResult = {
  success: boolean;
  error?: string;
};

export async function changeAvatar(values: FormData): Promise<ServerActionResult> {
  const avatar = values.get("avatar");
  const result = safeParse(changeAvatarSchema, { avatar }, { abortEarly: true });

  if (result.success) {
    return { success: true };
  } else {
    return { success: false, error: result.issues[0].message };
  }
}

Validation:

import { Input, instance, maxSize, mimeType, minLength, object, string } from "valibot";

export const changeAvatarSchema = object({
  avatar: instance(File, "Avatar is required", [
    mimeType(["image/jpeg", "image/png"], "Avatar must be either a JPG or PNG image"),
    maxSize(1024 * 1024 * 1, "Avatar size may not exceed 1 MB"),
  ]),
});

export type changeAvatarInput = Input<typeof changeAvatarSchema>;

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
question Further information is requested
Projects
None yet
Development

No branches or pull requests

2 participants