はじめに
表題通りです。
成果物
ソースコード
src/UpdateProduct.tsx
import type React from "react";
import { Suspense } from "react";
import { Button } from "@mui/material";
import { SearchAutocomplete } from "./form/Form";
import { useProductQuery, useSearchAutocomplete } from "./form/hooks";
import type { Product } from "./form/type";
const PRODUCT_ID = 1;
// 製品データ取得後に表示するコンポーネント
const UpdateProductComponent = ({ product }: { product: Product }) => {
const {
control,
categoryOptions,
isCategoryLoading,
subCategoryOptions,
isSubCategoryLoading,
setValue,
dirtyFields,
onSubmit,
onCancel,
} = useSearchAutocomplete(product);
return (
<div>
<h1>UpdateProduct</h1>
<SearchAutocomplete
control={control}
categoryOptions={categoryOptions}
isCategoryLoading={isCategoryLoading}
subCategoryOptions={subCategoryOptions}
isSubCategoryLoading={isSubCategoryLoading}
setValue={setValue}
/>
{dirtyFields && (
<div>
{Object.keys(dirtyFields).length > 0
? "以下のフィールドが変更されました:"
: "変更はありません"}
<ul>
{Object.keys(dirtyFields).map((field) => (
<li key={field}>{field}</li>
))}
</ul>
<Button
onClick={onSubmit}
disabled={Object.keys(dirtyFields).length === 0}
sx={{
width: 300,
backgroundColor:
Object.keys(dirtyFields).length === 0 ? "gray" : "blue", // disabledの時は灰色
color: "white",
"&:hover": {
backgroundColor:
Object.keys(dirtyFields).length === 0 ? "gray" : "darkblue", // hover時の色も設定
},
}}
>
保存
</Button>
<Button onClick={onCancel}>キャンセル</Button>
</div>
)}
</div>
);
};
// Suspenseによるローディング処理のラップ
export const UpdateProduct = () => {
return (
<Suspense fallback={<div>Loading...</div>}>
<ProductLoader />
</Suspense>
);
};
// 非同期に製品をロードしてから表示するコンポーネント
const ProductLoader: React.FC = () => {
const { data: product, isLoading } = useProductQuery(PRODUCT_ID);
if (isLoading || !product) {
return <div>Loading...</div>; // ローディング状態の表示
}
return <UpdateProductComponent product={product} />;
};
src/form/Form.tsx
import {
type Control,
Controller,
type UseFormSetValue,
} from "react-hook-form";
import { TextField, Autocomplete } from "@mui/material";
import type { Category, FormValues } from "./type";
type Props = {
control: Control<FormValues, unknown>;
categoryOptions: Category[];
isCategoryLoading: boolean;
subCategoryOptions: Category[];
isSubCategoryLoading: boolean;
setValue: UseFormSetValue<FormValues>;
};
// ユーザー検索のためのAutocompleteコンポーネント
export const SearchAutocomplete = ({
control,
categoryOptions,
isCategoryLoading,
subCategoryOptions,
isSubCategoryLoading,
setValue,
}: Props) => {
return (
<form>
{/* 親カテゴリーのAutocomplete */}
<Controller
name="category"
control={control}
render={({ field }) => (
<Autocomplete
{...field}
sx={{ width: 300 }}
options={categoryOptions}
getOptionLabel={(option: Category) => option.name} // nameフィールドを表示
loading={isCategoryLoading}
value={field.value}
onChange={(event, value) => {
field.onChange(value); // 親カテゴリー選択時にフィールドを更新
setValue("subCategory", null); // 親カテゴリー変更時にサブカテゴリーをリセット
}}
renderInput={(params) => (
<TextField
{...params}
label="親カテゴリー検索"
variant="outlined"
InputProps={{
...params.InputProps,
}}
/>
)}
/>
)}
/>
{/* サブカテゴリーのAutocomplete */}
<Controller
name="subCategory"
control={control}
render={({ field }) => (
<Autocomplete
{...field}
sx={{ width: 300, marginTop: "16px" }}
options={subCategoryOptions}
getOptionLabel={(option: Category) => option.name} // nameフィールドを表示
loading={isSubCategoryLoading}
value={field.value}
onInputChange={(event, value) => {
if (field.value) {
setValue("subCategory", {
...field.value,
name: value,
id: field.value.id ?? 0,
});
}
}} // サブカテゴリーの入力変更時にsearchNameを更新
onChange={(event, value) => field.onChange(value)} // サブカテゴリー選択時にフィールドを更新
renderInput={(params) => (
<TextField
{...params}
label="サブカテゴリー検索"
variant="outlined"
InputProps={{
...params.InputProps,
}}
/>
)}
/>
)}
/>
</form>
);
};
src/form/hooks.ts
import { useQuery } from "@tanstack/react-query";
import { fetchCategories, fetchProduct, fetchSubCategories } from "./api";
import type { FormValues, Product } from "./type";
import { useForm } from "react-hook-form";
// カスタムフック: カテゴリのオプションを取得
export const useCategoryOptions = (searchName: string) => {
return useQuery({
queryKey: ["categories", searchName], // クエリキーをsearchNameに基づいて更新
queryFn: () => fetchCategories(searchName), // クエリ関数
enabled: true, // 初期ロード時もクエリを実行
});
};
// サブカテゴリのオプションを取得するカスタムフック
export const useSubCategoryOptions = (
categoryId: number | null,
searchName: string,
) => {
return useQuery({
queryKey: ["subCategories", categoryId, searchName], // categoryIdとsearchNameに基づいてクエリを実行
queryFn: () => {
if (categoryId === null) {
return Promise.resolve([]); // categoryIdがnullの場合は空配列を返す
}
return fetchSubCategories(categoryId, searchName); // サブカテゴリーのAPI呼び出し
},
enabled: !!categoryId, // categoryIdがnullでない場合のみ実行
});
};
// 製品データを取得するカスタムフック
export const useProductQuery = (productId: number) => {
return useQuery({
queryKey: ["product", productId], // 製品IDに基づいてクエリを実行
queryFn: () => fetchProduct(productId), // 製品データの取得
});
};
export const useSearchAutocomplete = (product: Product) => {
const {
control,
watch,
setValue,
formState: { dirtyFields },
reset,
} = useForm<FormValues>({
defaultValues: {
category: product.category,
subCategory: product.subCategory,
},
});
// 親カテゴリーの監視
const selectedCategory = watch("category");
const categoryId = selectedCategory?.id || null;
// 親カテゴリーの検索に基づくオプションを取得
const { data: categoryOptions = [], isLoading: isCategoryLoading } =
useCategoryOptions(selectedCategory?.name || "");
// サブカテゴリー検索のための監視
const subCategorySearchName = watch("subCategory")?.name || "";
// サブカテゴリーのオプションを取得
const { data: subCategoryOptions = [], isLoading: isSubCategoryLoading } =
useSubCategoryOptions(categoryId, subCategorySearchName);
const onSubmit = () => {
console.log("onSubmit", dirtyFields);
};
const onCancel = () => {
reset(); // フォームの値を初期値にリセット
};
return {
control,
categoryOptions,
isCategoryLoading,
subCategoryOptions,
isSubCategoryLoading,
setValue,
dirtyFields,
onSubmit,
onCancel,
};
};
src/form/type.ts
export type Category = {
id: number;
key: string;
name: string;
};
// FormValuesの型定義
export type FormValues = {
category: Category | null; // 親カテゴリー
subCategory: Category | null; // サブカテゴリー
};
// 製品の型
export type Product = {
id: number;
category: Category;
subCategory: Category;
};