カレンダーアプリを作る(フロントエンド編①)
概要
フルスタックなアプリケーション開発の練習として、カレンダーアプリを作成することにしました。
Googleカレンダーを参考にしながら作成しています。
参考サイト:
技術スタック
- ライブラリ:
- React(18系)
- @fullcalendar/react
- @fullcalendar/daygrid
- @fullcalendar/timegrid
- @fullcalendar/interaction
- date-fns
- @types/react-datepicker
- UIコンポーネント: shadcn/ui
- ビルド: Vite
機能要件
主な機能
- カレンダーの表示
- 日付を選択して、イベントの追加(予定(日時)、タイトル)ができる。
- 入力済みのイベントをクリックすると、ポップアップが表示される。ポップアップには、タイトル、日時、編集ボタン、削除ボタン、閉じるボタンが表示される。
- 編集ボタンを押下すると編集用ダイアログを表示し、タイトル、日時を編集できる。閉じる、保存ボタンが存在する。
- 削除ボタンを押すと予定が削除される
ざっくりとカレンダーアプリとして使えるような機能を洗い出しました。
細かいところは後々調整予定です。
今回は二つ目の日付を選択して、カレンダーにイベントの追加ができるところまで実装しようと思います。
実装
環境構築
Viteを使って環境構築をします。
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: » React
√ Select a variant: » TypeScript
プロジェクトが作成できたら、
cd calendar-mock-app
yarn install
yarn dev
続いてshadcn/uiをインストールします。
下記のドキュメントを参照してください。
ライブラリもインストールします
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を使います。詳しくは下記のドキュメントを参照してください。
// /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で難しかった部分は下記のスクラップにまとめておきました。
Discussion