📆

カレンダーアプリを作る(フロントエンド編①)

に公開

概要

フルスタックなアプリケーション開発の練習として、カレンダーアプリを作成することにしました。
Googleカレンダーを参考にしながら作成しています。

参考サイト:
https://katsuya-place.com/react-fullcalendar/

技術スタック

  • ライブラリ:
    • React(18系)
    • @fullcalendar/react
    • @fullcalendar/daygrid
    • @fullcalendar/timegrid
    • @fullcalendar/interaction
    • date-fns
    • @types/react-datepicker
  • UIコンポーネント: shadcn/ui
  • ビルド: Vite

機能要件

主な機能

  • カレンダーの表示
  • 日付を選択して、イベントの追加(予定(日時)、タイトル)ができる。
  • 入力済みのイベントをクリックすると、ポップアップが表示される。ポップアップには、タイトル、日時、編集ボタン、削除ボタン、閉じるボタンが表示される。
  • 編集ボタンを押下すると編集用ダイアログを表示し、タイトル、日時を編集できる。閉じる、保存ボタンが存在する。
  • 削除ボタンを押すと予定が削除される

ざっくりとカレンダーアプリとして使えるような機能を洗い出しました。
細かいところは後々調整予定です。

今回は二つ目の日付を選択して、カレンダーにイベントの追加ができるところまで実装しようと思います。

実装

環境構築

Viteを使って環境構築をします。
https://ja.vite.dev/guide/

yarn create vite
yarn create v1.22.22
[1/4] Resolving packages...
[2/4] Fetching packages...
[3/4] Linking dependencies...
[4/4] Building fresh packages...
success Installed "create-vite@5.5.5" with binaries:
      - create-vite
      - cva
√ Project name: ... calendar-mock-app
√ Select a framework: » ReactSelect a variant: » TypeScript

プロジェクトが作成できたら、

cd calendar-mock-app
yarn install
yarn dev

続いてshadcn/uiをインストールします。
下記のドキュメントを参照してください。
https://ui.shadcn.com/docs/installation/vite

ライブラリもインストールします

yarn add @fullcalendar/react @fullcalendar/core @fullcalendar/daygrid @fullcalendar/timegrid @fullcalendar/interaction date-fns @types/react-datepicker react-hook-form

実装1 カレンダーの表示

とりあえずカレンダーを表示してみます。

// /src/components/Calendar/CalendarPage.tsx

// FullCalendarコンポーネント。
import FullCalendar from '@fullcalendar/react'

// FullCalendarコンポーネント。
import FullCalendar from "@fullcalendar/react";

// FullCalendarで週表示を可能にするモジュール。
import timeGridPlugin from "@fullcalendar/timegrid";

// FullCalendarで月表示を可能にするモジュール。
import dayGridPlugin from "@fullcalendar/daygrid";

// FullCalendarで日付や時間が選択できるようになるモジュール。
import interactionPlugin from "@fullcalendar/interaction";

export const CalendarPage = () => {
  return (
    <div>
      <FullCalendar
        locale="ja" // 言語を日本語に設定
        allDayText="終日" // 「終日」の表示用テキスト
        height="auto" // ヘッダーとフッターを含むカレンダー全体の高さを設定する
        plugins={[timeGridPlugin, dayGridPlugin, interactionPlugin]} // プラグインを読み込む
        initialView="dayGridMonth" // カレンダーが読み込まれたときの初期ビュー
        slotDuration="00:30:00" // タイムスロットを表示する頻度。
        //ユーザーはクリックしてドラッグすることで、複数の日付または時間帯を強調表示できます。
        //ユーザーがクリックやドラッグで選択できるようにするには、インタラクション・プラグインをロードし、このオプションをtrueに設定する必要があります。
        selectable={true}
        businessHours={{
          daysOfWeek: [1, 2, 3, 4, 5],
          startTime: "00:00",
          endTime: "24:00",
        }} // カレンダーの特定の時間帯を強調します。 デフォルトでは、月曜日から金曜日の午前9時から午後5時までです。
        weekends={true} // カレンダービューに土曜日/日曜日の列を含めるかどうか。
        titleFormat={{
          year: "numeric",
          month: "short",
        }}
        headerToolbar={{
          start: "title", // タイトルを左に表示する。
          center: "prev,next,today", // 「前月を表示」、「次月を表示」、「今日を表示」ができるボタンを画面の中央に表示する。
          end: "dayGridMonth,timeGridWeek", // 月・週表示を切り替えるボタンを表示する。
        }} // headerToolbarのタイトルに表示されるテキストを決定します。
      />
    </div>
  );
};

下図のような感じで表示されました。

実装2 スケジュール登録用フォームの作成

続いて、スケジュールを登録するためのフォームを作成していきます。
Shadcn/uiのFormコンポーネントとZodを使います。詳しくは下記のドキュメントを参照してください。
https://ui.shadcn.com/docs/components/form

// /src/components/Calendar/parts/Form/ScheduleForm.tsx

import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { CalendarIcon } from "lucide-react";
import { ja } from "date-fns/locale";
import { format } from "date-fns";

export const ScheduleForm = () => {
  const [eventsTitle, setEventsTitle] = useState("");
  const [isAllDay, setIsAllDay] = useState<boolean>(false);
  const [eventsStartDate, setEventsStartDate] = useState<Date>();
  const [eventsStartTime, setEventsStartTime] = useState("");
  const [eventsEndDate, setEventsEndDate] = useState<Date>();
  const [eventsEndTime, setEventsEndTime] = useState("");

  // フォーム用のスキーマ
  const scheduleSchema = z.object({
    title: z
      .string()
      .min(1, { message: "Title must be at least 1 characters." }),
    all_day: z.boolean().default(false).optional(),
    start_date: z.date({
      required_error: "A date of start schedule is required.",
    }),
    start_time: z
      .string()
      .min(4, { message: "A time of start schedule is required" }),
    end_date: z.date({ required_error: "A date of end schedule is required." }),
    end_time: z
      .string()
      .min(4, { message: "A time of end schedule is required" }),
  });

  const form = useForm<z.infer<typeof scheduleSchema>>({
    resolver: zodResolver(scheduleSchema),
  });

  const addEvent = () => {
    // TODO:スケジュール登録処理
  };

  /**
   * Date型のオブジェクトを”〇〇月〇〇日(曜日)”の形にフォーマットする
   * @param date
   * @returns {string} 日本語にフォーマットした日付を返す
   */
  const formatCaption = (date: Date | undefined) => {
    if (!date) {
      return;
    }
    const dayArr = ["日", "月", "火", "水", "木", "金", "土"];
    const day = format(date, "MM月dd日");
    return `${day}(${dayArr[date.getDay()]})`;
  };

  const handleToggle = () => {
    setIsAllDay((prev: boolean) => !prev); // 状態をトグルする関数
  };

  return (
    <div>
      <Form {...form}>
        <form onSubmit={form.handleSubmit(addEvent)} className="space-y-4">
          <FormField
            control={form.control}
            name="title"
            render={({ field }) => (
              <FormItem className="w-md">
                <FormLabel>タイトル</FormLabel>
                <FormControl>
                  <Input
                    {...field}
                    placeholder="title"
                    onChange={(e) => {
                      field.onChange(e.target.value); // field.onChangeを呼び出す
                      setEventsTitle(e.target.value);
                    }}
                  />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />
          <FormField
            control={form.control}
            name="all_day"
            render={({ field }) => (
              <FormItem className="flex w-md justify-between">
                <FormLabel>終日</FormLabel>
                <FormControl>
                  <Switch
                    checked={field.value}
                    onCheckedChange={() => {
                      field.onChange();
                      handleToggle();
                    }}
                  />
                </FormControl>
              </FormItem>
            )}
          />
          <div className="flex justify-between w-md">
            <FormField
              control={form.control}
              name="start_date"
              render={({ field }) => (
                <FormItem className="w-full mr-2">
                  <FormLabel>開始日</FormLabel>
                  <Popover>
                    <PopoverTrigger asChild>
                      <Button
                        variant={"outline"}
                        className={cn(
                          "justify-start text-left font-normal",
                          !field.value && "text-muted-foreground"
                        )}
                      >
                        <CalendarIcon />
                        <span>{formatCaption(field.value)}</span>
                      </Button>
                    </PopoverTrigger>
                    <PopoverContent>
                      <Calendar
                        locale={ja}
                        mode="single"
                        selected={field.value}
                        onSelect={(date) => {
                          setEventsStartDate(date);
                          field.onChange(date);
                        }}
                        initialFocus
                      />
                    </PopoverContent>
                  </Popover>
                  <FormMessage />
                </FormItem>
              )}
            />
            <FormField
              control={form.control}
              name="start_time"
              render={({ field }) => (
                <FormItem className="w-full">
                  <FormLabel>開始時刻</FormLabel>
                  <FormControl>
                    <Input
                      type="time"
                      value={field.value}
                      onChange={(e) => {
                        field.onChange(e.target.value);
                        setEventsStartTime(e.target.value);
                      }}
                    />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
          </div>
          <div className="flex justify-between w-md">
            <FormField
              control={form.control}
              name="end_date"
              render={({ field }) => (
                <FormItem className="w-full mr-2">
                  <FormLabel>終了日</FormLabel>
                  <Popover>
                    <PopoverTrigger asChild>
                      <FormControl>
                        <Button
                          variant={"outline"}
                          className={cn(
                            "justify-start text-left font-normal",
                            !field.value && "text-muted-foreground"
                          )}
                        >
                          <CalendarIcon />
                          <span>{formatCaption(field.value)}</span>
                        </Button>
                      </FormControl>
                    </PopoverTrigger>
                    <PopoverContent>
                      <Calendar
                        locale={ja}
                        mode="single"
                        selected={field.value}
                        onSelect={(date) => {
                          setEventsEndDate(date);
                          field.onChange(date); // Update the form state with the selected date
                        }}
                        initialFocus
                      />
                    </PopoverContent>
                  </Popover>
                  <FormMessage />
                </FormItem>
              )}
            />
            <FormField
              control={form.control}
              name="end_time"
              render={({ field }) => (
                <FormItem className="w-full">
                  <FormLabel>終了時刻</FormLabel>
                  <FormControl>
                    <Input
                      type="time"
                      value={field.value}
                      onChange={(e) => {
                        field.onChange(e.target.value);
                        setEventsEndTime(e.target.value);
                      }}
                    />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
          </div>
          <Button type="submit">保存</Button>
        </form>
      </Form>
    </div>
  );
};

下図のようなフォームが作成されました。

formCaptionでは、引数で受け取ったDateオブジェクトをdate-fnsのformat関数でMM月dd日に変換しています。
また、date.getDay()で曜日のインデックスを取得することができます。
このインデックスを用いて、あらかじめ用意しておいた日本語の曜日の配列から、該当の曜日を取得し、MM月dd日(曜日)に変換しています。

実装3 カレンダー上で日時を選択し、フォームに反映する

FullCalendarコンポーネントにselectプロップスがあります。
関数を設定してあげると、カレンダー上で日付を選択した際、その情報を取得することができます

// /src/components/Calendar/CalendarPage.tsx
 ...
  const handleSelect = (selectedInfo: any) => {
    console.log(selectedInfo);
  };
  return (
    <FullCalendar
        ...
        select={handleSelect}
    />

各情報を取得することができました。

取得したデータをフォームに反映します。
カスタムフックを作成し、取得したデータをステートで管理します。

// /src/components/Calendar/hooks/useCalendarFunc.tsx

import { format } from "date-fns";
import { useState } from "react";

export const useCalendarFunc = () => {
  const [eventsTitle, setEventsTitle] = useState("");
  const [isAllDay, setIsAllDay] = useState<boolean>(false);
  const [eventsStartDate, setEventsStartDate] = useState<Date>();
  const [eventsStartTime, setEventsStartTime] = useState("");
  const [eventsEndDate, setEventsEndDate] = useState<Date>();
  const [eventsEndTime, setEventsEndTime] = useState("");
  const [isOpenSheet, setIsOpenSheet] = useState<boolean>(false);

  /**
   * カレンダー上で選択した日付をセットステートに反映する
   * @param selectedInfo
   */
  const handleSelect = (selectedInfo: any) => {
    const start_date = new Date(selectedInfo.start);
    const start_time = format(start_date, "HH:mm");
    const end_date = new Date(selectedInfo.end);
    const end_time = format(end_date, "HH:mm");
    setEventsTitle("");
    setIsAllDay(false);
    setEventsStartDate(start_date);
    setEventsStartTime(start_time);
    setEventsEndDate(end_date);
    setEventsEndTime(end_time);
    setIsOpenSheet(true);
  };

  return {
    eventsTitle,
    setEventsTitle,
    isAllDay,
    setIsAllDay,
    eventsStartDate,
    setEventsStartDate,
    eventsEndDate,
    setEventsEndDate,
    eventsStartTime,
    setEventsStartTime,
    eventsEndTime,
    setEventsEndTime,
    isOpenSheet,
    setIsOpenSheet,
    handleSelect,
  };
};

カレンダーを表示しているコンポーネントで、カスタムフックをインポートし、スケジュール登録用フォームコンポーネントに各ステートを渡します。

// /src/components/Calendar/CalendarPage.tsx
...
import { ScheduleForm } from "./parts/Form/ScheduleForm";
import { useCalendarFunc } from "./hooks/useCalendarFunc";

export const CalendarPage = () => {
  const {
    eventsTitle,
    setEventsTitle,
    isAllDay,
    setIsAllDay,
    eventsStartDate,
    setEventsStartDate,
    eventsEndDate,
    setEventsEndDate,
    eventsStartTime,
    setEventsStartTime,
    eventsEndTime,
    setEventsEndTime,
    isOpenSheet,
    setIsOpenSheet,
    handleSelect,
  } = useCalendarFunc();

  return (
    <div>
      <ScheduleForm
        title={{ eventsTitle, setEventsTitle }}
        allDay={{ isAllDay, setIsAllDay }}
        startDate={{ eventsStartDate, setEventsStartDate }}
        startTime={{ eventsStartTime, setEventsStartTime }}
        endDate={{ eventsEndDate, setEventsEndDate }}
        endTime={{ eventsEndTime, setEventsEndTime }}
      />
      <FullCalendar
...

スケージュール登録用フォームコンポーネントでステートを受け取ります。
受け取ったステートは、useFormのsetValueを使って初期値として設定してげます。
useEffectで日付のステートが更新されたタイミングでsetValueに値を設定するようにします。

// /src/components/Calendar/parts/Form/ScheduleForm.tsx

export const ScheduleForm = (props: any) => {
  const { title, allDay, startDate, startTime, endDate, endTime } = props;
  const { eventsTitle, setEventsTitle } = title;
  const { isAllDay, setIsAllDay } = allDay;
  const { eventsStartDate, setEventsStartDate } = startDate;
  const { eventsStartTime, setEventsStartTime } = startTime;
  const { eventsEndDate, setEventsEndDate } = endDate;
  const { eventsEndTime, setEventsEndTime } = endTime;

  const handleToggle = (e: boolean) => {
    setIsAllDay(e); // 状態をトグルする関数
    // 終日設定がTrueの時、開始時間と終了時間を00:00にする。
    if (e) {
      form.setValue("start_time", "00:00");
      setEventsStartTime("00:00");
      form.setValue("end_time", "00:00");
      setEventsEndTime("00:00");
    }
  };

  useEffect(() => {
    form.setValue("start_date", eventsStartDate);
    form.setValue("start_time", eventsStartTime);
    form.setValue("end_date", eventsEndDate);
    form.setValue("end_time", eventsEndTime);
    form.setValue("title", eventsTitle);
    form.setValue("all_day", isAllDay);
  }, [eventsStartDate]);
  ...
  return (
    <div>
      <Form {...form}>
  ...

カレンダー上で日付を選択し、フォームに反映することができました。

また、しっかりとバリデーションも機能しています。

実装4 スケージュールをカレンダーに登録する

フォームで入力したスケジュールをカレンダーに登録します。
FullCalendarにはeventsというプロップスがあるので、ここに登録するスケジュールの情報を配列で渡してあげます。

      <FullCalendar
        ...
        ref={ref}
        select={handleSelect}
        events={myEvents}
      />

myEventsの中身は、下記のようなオブジェクトの配列になっています。

[
 {
  id:string,
  title:string,
  start:Date,
  end:Date,
  allDay:boolean
 },
 ...
]

また、FullCalendarにrefを設定しています。
FullCalendarでは、refを使用してカレンダーのインスタンスにアクセスし、以下のような操作を行うことができます。

  • カレンダーのメソッド呼び出し: refを使ってカレンダーのメソッド(例えば、日付の変更やイベントの追加)を呼び出すことができます。これにより、カレンダーの状態をプログラム的に制御できます。
  • 状態の管理: refを使用することで、カレンダーの状態を外部から管理することができます。これにより、ユーザーの操作に応じてカレンダーの表示を動的に変更できます。

実装としては、以下のようになります。

// /src/components/Calendar/hooks/useCalendarFunc.tsx

import FullCalendar from "@fullcalendar/react";
import { format } from "date-fns";
import { createRef, useState } from "react";

export const useCalendarFunc = () => {
  ...
  const ref = createRef<FullCalendar>(); // 追加
  const [myEvents, setMyEvents] = useState<any[]>([]); // 追加

  ...

  /**
   * フォームに入力したスケジュールをカレンダーに登録する処理
   */
  const onAddEvent = async () => {
    if (!ref.current) {
      return;
    }
    if (!eventsStartDate || !eventsEndDate) {
      return;
    }

    // 開始時間と終了時間はString型のため、一度数値に変換し、Date型であるeventsStartDateとeventsEndDateにセットする
    const [sh, sm] = eventsStartTime.split(":").map(Number);
    const [eh, em] = eventsEndTime.split(":").map(Number);
    eventsStartDate.setHours(sh);
    eventsStartDate.setMinutes(sm);
    eventsEndDate.setHours(eh);
    eventsEndDate.setMinutes(em);

    if (eventsStartDate >= eventsEndDate) {
      alert("開始時間と終了時間を確認してください");
      return;
    }

    const event = {
      id: String(myEvents.length),
      title: eventsTitle,
      start: eventsStartDate,
      end: eventsEndDate,
    };
    setMyEvents([...myEvents, event]);
    // カレンダーに予定を登録して表示するための処理。
    ref.current.getApi().addEvent(event);
  };

  return {
    ...

    ref, // 追加
    myEvents, // 追加
    onAddEvent, // 追加
  };
};
// /src/components/Calendar/parts/Form/ScheduleForm.tsx

export const ScheduleForm = (props: any) => {
  const { title, allDay, startDate, startTime, endDate, endTime, addEvent } =
    props; // addEventを追加

  ...


  return (
    <div>
      <Form {...form}>
        // addEventを設定
        <form onSubmit={form.handleSubmit(addEvent)} className="space-y-4">

         ...

          <Button type="submit">保存</Button>
        </form>
      </Form>
    </div>
  );
};

スケジュールを追加できるようになりました。

まとめ

今回は、カレンダーにスケジュールを登録できるようにしました。
React FullCalendarが様々な便利な機能を用意してくれているので、比較的簡単に実装することができました。
一方でReact Hook Formの使い方が難しく、useStateと組み合わせるとバリデーションがうまく機能しないなどの不具合があり、苦戦してしまいました。今回学んだことは他のアプリケーションを作る際にも活かしていきたいと思います。

今回React Hook Formで難しかった部分は下記のスクラップにまとめておきました。
https://zenn.dev/k_m_i/scraps/2b18e9d3e4fc62

Discussion