るちtechブログ

とあるサイトを作っています

MUI x Yup x Day.jsをいい感じに使う

モチベーション

MUI x Yup x Day.jsを使ってDateTimePickerを使おうとしたら色々詰まってしまった。特にバリデーション周りで。

Googleで検索してもChatGPTに聞いてもYupとDayjsを組み合わせていい感じに場リテーションする方法がほとんど見つからなかった。

Typescriptで使う場合に型をしっかりつけたいので、Yupの拡張メソッドに型を当てる方法を調べたりChatGPTに聞いたりしたがYupの破壊的アップデートによってうまく動かない(しかもYupのドキュメントには詳しく書いていない...)

使用技術スタック

  • NextJs(App Routerを使用)
  • MUI (いい感じのUIのため)
  • Yup (バリデーションライブラリ)

実行結果

今回のコードでは、今の日時~今の日時から1週間後までの値のみを受け付けるようにします。
記事を書いてる日時です。

2分後なのでOK

1ヶ月前なのでだめ

1週間以上後なのでだめ

年と分が未入力なのでだめ

こんな感じに動作します。いい感じでしょ?

コード

lib/yup/yup.custom.ts

import dayjs from "dayjs";
import * as yup from "yup";

declare module "yup" {
  interface MixedSchema {
    valid(message?: string | undefined): this;
    minDate(
      minDate: dayjs.Dayjs,
      allowSameDate: boolean,
      message?: string | undefined
    ): this;
    maxDate(
      maxDate: dayjs.Dayjs,
      allowSameDate: boolean,
      message?: string | undefined
    ): this;
  }
}

function checkValidDayjs(value: any): dayjs.Dayjs | undefined {
  if (!value) return undefined;

  if (dayjs.isDayjs(value) == false) return undefined;
  try {
    return dayjs(value).isValid() ? value : undefined;
  } catch (e) {
    return undefined;
  }
}

yup.addMethod(yup.mixed, "valid", function (this, message) {
  return this.test(
    "isValidDayjsInstance",
    message ? message : "Invalid value",
    (value) => {
      return checkValidDayjs(value) != undefined;
    }
  );
});

yup.addMethod(
  yup.mixed,
  "minDate",
  function (this, minDate, allowSameDate, message) {
    return this.test(
      "minDate",
      message ? message : "Date is too early",
      (value) => {
        const checkedValue = checkValidDayjs(value);
        if (!checkedValue) return false;

        return allowSameDate
          ? checkedValue.isAfter(minDate, "minute") ||
              checkedValue.isSame(minDate, "minute")
          : checkedValue.isAfter(minDate, "minute");
      }
    );
  }
);

yup.addMethod(
  yup.mixed,
  "maxDate",
  function (this, maxDate, allowSameDate, message) {
    return this.test(
      "maxDate",
      message ? message : "Date is too late",
      (value) => {
        const checkedValue = checkValidDayjs(value);
        if (!checkedValue) return false;

        return allowSameDate
          ? checkedValue.isBefore(maxDate, "minute") ||
              checkedValue.isSame(maxDate, "minute")
          : checkedValue.isBefore(maxDate, "minute");
      }
    );
  }
);

export default yup;

src/app/page.tsx

"use client";

import { yupResolver } from "@hookform/resolvers/yup";
import { Box, Button, Container } from "@mui/material";
import {
  DateTimePicker as DateTimePickerBase,
  LocalizationProvider,
} from "@mui/x-date-pickers";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import dayjs from "dayjs";
import "dayjs/locale/ja";
import { useCallback } from "react";
import { Controller, useForm } from "react-hook-form";
import * as yup from "yup";
// これがないと拡張メソッドを読み込まない
import "@/libs/yup/yup.custom";

type Inputs = {
  datetime: dayjs.Dayjs;
};

const validationSchema = yup.object().shape({
  datetime: yup
    .mixed<dayjs.Dayjs>()
    .valid("正しい日時を入力してください")
    .required("目標達成日時を入力してください")
    .minDate(dayjs(), false, "現在の日時より後に設定してください")
    .maxDate(
      dayjs().add(7, "day"),
      false,
      "現在の日時より1週間以内に設定してください。"
    ),
});

export default function Page() {
  const {
    control,
    handleSubmit,
    formState: { errors },
  } = useForm<Inputs>({
    defaultValues: {
      datetime: undefined,
    },
    resolver: yupResolver(validationSchema),
  });

  const onSubmit = useCallback(async (data: Inputs) => {
    const { datetime } = data;
    console.log(datetime.toDate().toString());
  }, []);

  return (
    <Container component="main">
      <Box
        component="form"
        onSubmit={handleSubmit(onSubmit)}
        noValidate
        sx={{ mt: 1 }}
      >
        <Controller
          name="datetime"
          control={control}
          defaultValue={dayjs()}
          render={({ field }) => (
            <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="ja">
              <DateTimePickerBase
                {...field}
                slotProps={{
                  textField: {
                    value: field.value,
                    error: errors.datetime !== undefined,
                    size: "medium",
                    helperText: errors.datetime?.message,
                    margin: "normal",
                    label: "日付と時間を入力できます",
                    id: "datetime",
                    autoComplete: "datetime",
                  },
                }}
              />
            </LocalizationProvider>
          )}
        />
        <br />
        <Button type="submit" variant="contained" size="large">
          送信
        </Button>
      </Box>
    </Container>
  );
}

ポイント

  • import "@/libs/yup/yup.custom";でカスタムメソッドを読み込むところと、DateTimePickerをLocalizationProviderで囲んでるところです。 この2つが無いとうまく動かないので(n敗)
  • dayjs用の拡張関数のvalid()関数にてisValid()をtry-catchで囲んでるところです。一部のフィールドが埋まってないdayjsのオブジェクトが渡されるとisDayjs()isValid()もtrueを返すので、渡された値を一回コンストラクタで初期化することでオブジェクトのフィールドが満たされてるかチェックしてます。変換できなかったときにnullやundefinedでなくエラーが投げられるのでそれをtry-catchすることでバリデーションしています。

短いですが以上です。参考になれば幸いです。

参考