React refineでサクッと管理画面を作る
Supported UI Frameworks Ant Design, Material UI, Tailwind, anything...
・・・
refineはReactベースの新しめのフレームワークです。チュートリアルを終えると基本的なCRUDな画面が出来上がるのでこれに手を加えていく
axiosセットアップ
Rest APIでのデータ取得にSimple REST APIで使用されているaxiosのセットアップを行い、dataProviderにそれを指定。ここではbaseURLをセットしているが、withcredentialsやインターセプターの記述などが書ける
・・・
export const API_URL = "https://api.fake-rest.refine.dev"
export const axiosInstance = axios.create();
axiosInstance.defaults.baseURL = API_URL;
・・・
const App: React.FC = () => {
return (
<Refine
dataProvider={dataProvider(API_URL, axiosInstance)}
・・・
/>
);
};
export default App;
ログイン
以下のメソッドを実装したauthProviderをRefineコンポーネントのauthProviderに指定することでログイン機能が実装できる
const authProvider = {
login: () => Promise.resolve(),
logout: () => Promise.resolve(),
checkAuth: () => Promise.resolve(),
checkError: () => Promise.resolve(),
getPermissions: () => Promise.resolve(),
getUserIdentity: () => Promise.resolve(),
};
ここではクッキーを使った認証を想定して、loginメソッドでログイン用のAPIへリクエストして成功したらログイン中のアカウント情報をローカルストレージに保存している。その他、checkErrorではAPIからのレスポンスでエラーが返ってきたときに認証エラーかどうかの判定方法を書く
import { axiosInstance } from "App";
import { HttpError } from "@pankod/refine-core";
export const authProvider = {
login: async ({ email, password }: { email: string; password: string }) => {
const res = await axiosInstance.post("/login", {email, password});
localStorage.setItem("auth", JSON.stringify(res.data));
},
logout: async () =>
localStorage.removeItem("auth"),
checkError: (error: HttpError) =>
(error.statusCode === 401 ? Promise.reject() : Promise.resolve()),
checkAuth: () =>
(localStorage.getItem("auth") ? Promise.resolve() : Promise.reject()),
getPermissions: () =>
Promise.resolve(),
getUserIdentity: () => {
const auth = localStorage.getItem("auth");
return auth ? Promise.resolve(JSON.parse(auth)) : Promise.reject();
},
};
・・・
import { authProvider } from "authProvider";
function App() {
・・・
return (
<Refine
authProvider={authProvider}
・・・
検索フォーム
Table Searchで検索フォームを追加する
タイトルのテキストフォームを追加。useTableのsyncWithLocation=trueを指定することで、検索条件とURLが同期される。また、FormコンポーネントのinitialValuesの指定でフォームのデフォルト値がセットされる。これでブラウザバックやリロードされてもフォームに初期値がセットされる
import {
CrudFilters,
・・・
} from "@pankod/refine-core";
import {
Form,
FormProps,
Row,
Col,
Input,
・・・
} from "@pankod/refine-antd";
interface IPostFilterVariables {
title?: string;
}
const Filter: React.FC<{ formProps: FormProps; filters: CrudFilters }> = (props) => {
const { formProps, filters } = props;
return (
<Form
{...formProps}
initialValues={{
title: getDefaultFilter("title", filters),
}}
>
<Form.Item label="title" name="title">
<Input />
</Form.Item>
<Form.Item>
<Button htmlType="submit">Search</Button>
</Form.Item>
</Form>
);
};
・・・
export const PostList: React.FC = () => {
const { tableProps, searchFormProps, filters } = useTable<IPost, HttpError, IPostFilterVariables>({
syncWithLocation: true,
onSearch: (params) => {
const filters: CrudFilters = [];
const { title } = params;
filters.push({
field: "title",
operator: "eq",
value: title ? title : undefined,
});
return filters;
},
});
・・・
return (
<List>
<Filter formProps={searchFormProps} filters={filters || []} />
<Table {...tableProps} rowKey="id">
・・・
}
続けて、statusのプルダウンとcreatedAtのDatePickerを追加。ここではSelectとDatePickerのonChangeで検索を実行している。また、SelectのallowClear指定でフォームのクリアーボタンを表示している
・・・
interface IPostFilterVariables {
・・・
status?: string;
createdAt?: dayjs.Dayjs;
}
const statuses = ["published", "draft", "rejected"];
const Filter: React.FC<{ formProps: FormProps; filters: CrudFilters }> = (props) => {
const { formProps, filters } = props;
・・・
const createdAt = useMemo(() => {
const value = getDefaultFilter("createdAt", filters);
return value ? dayjs(value) : null;
}, [filters]);
return (
<Form {...formProps}
initialValues={{
・・・
status: getDefaultFilter("status", filters),
createdAt: createdAt,
}}
>
・・・
<Form.Item label="status" name="status">
<Select allowClear
options={statuses.map((status) => {
return { value: status, label: status };
})}
onChange={() => formProps.form?.submit()}
></Select>
</Form.Item>
<Form.Item label="createdAt" name="createdAt">
<DatePicker onChange={() => formProps.form?.submit()} />
</Form.Item>
・・・
</Form>
);
};
export const PostList: React.FC = () => {
const { tableProps, searchFormProps, filters } = useTable<IPost, HttpError, IPostFilterVariables>({
onSearch: (params) => {
const filters: CrudFilters = [];
const { title, status } = params;
・・・
filters.push({
field: "status",
operator: "eq",
value: status ? status : undefined,
});
filters.push({
field: "createdAt",
operator: "eq",
value: createdAt?.format("YYYY-MM-DD"),
});
return filters;
},
});
i18nで日本語に対応
i18n Providerで日本語に対応する
supportedLngsとfallbackLngをjaに変更
i18n
.use(Backend)
.use(detector)
.use(initReactI18next)
.init({
supportedLngs: ["ja"],
backend: {
loadPath: "/locales/{{lng}}/{{ns}}.json",
},
defaultNS: "common",
fallbackLng: ["ja"],
});
・・・
const App: React.FC = () => {
const { t, i18n } = useTranslation();
const i18nProvider = {
translate: (key: string, params: object) => t(key, params),
changeLocale: (lang: string) => i18n.changeLanguage(lang),
getLocale: () => i18n.language,
};
return (
<Refine
i18nProvider={i18nProvider}
・・・
/>
);
};
public/locales/enフォルダをコピーしてjaフォルダを作成。英語の文字列リソースを必要に応じて日本語に変えていく
{
"pages": {
"login": {
"signin": "ログイン",
},
・・・
"posts": {
"posts": "投稿",
"fields": {
"title": "タイトル",
・・・
},
また、各ページで文字列リソースを使う
・・・
import { useTranslation } from "react-i18next";
export const PostList: React.FC = () => {
・・・
const { t } = useTranslation();
return (
<List title={t("posts.posts")}>
<Table {...tableProps} rowKey="id">
<Table.Column dataIndex="title" title={t("posts.fields.title")} />
・・・
);
}
バリデーション
Form.ItemのrulesプロパティでAnt Designのバリデーションルールが指定できる
<Form.Item
label="title"
name="title"
rules={[{ required: true, message: "入力してください" }]}
>
<Input />
</Form.Item>
Custom Inputs
Custom Inputsに書かれている。Ant Designのカスタムフォームの説明にあるように、valueとonChangeプロパティを実装したコンポーネントを作成
export type CategoryFormProps = {
value?: number;
onChange?: (value: number | undefined) => void;
};
export const CategoryForm: React.FC<CategoryFormProps> = ({ value, onChange }) => {
const { listProps, searchFormProps, queryResult } = useSimpleList<ICategory,HttpError>({
resource: "categories",
pagination: { pageSize: 5 },
});
const { data } = queryResult;
const { modalProps, show, close } = useModal();
const [selectCategory, setSelectCategory] = useState<number | undefined>(value);
useEffect(() => onChange?.(selectCategory), [onChange, selectCategory]);
return (
<>
<Row>
<Col>{selectCategory && <>{data?.data.find((x) => x.id == selectCategory)?.title}</>}</Col>
<Col>
<Button onClick={() => show()}>
選択する
</Button>
</Col>
</Row>
<Modal {...modalProps}>
<AntdList
{...listProps}
dataSource={data?.data}
renderItem={(category) => (
<AntdList.Item>
<Card title={category.id}>
<a
onClick={() => {
setSelectCategory(category.id);
close();
}}
>
{category.title}
</a>
</Card>
</AntdList.Item>
)}
/>
</Modal>
</>
);
};
他のフォームアイテムと同じようにバリデーションルールも使える
export const PostCreate = () => {
・・・
return(
<Form.Item
label="category"
name={["category", "id"]}
rules={[{ required: true, message: "入力してください" }]}
>
<CategoryForm />
</Form.Item>
);
}
スピナーの表示
登録や更新中にボタンにスピナーを表示する
export const PostCreate = () => {
const { formProps, saveButtonProps } = useForm<IPost, HttpError, IPost>({
onMutationSuccess: () => setIsSaving(false),
onMutationError: () => setIsSaving(false),
});
const [isSaving, setIsSaving] = useState(false);
・・・
return (
<Create saveButtonProps={{ ...saveButtonProps, loading: isSaving }}>
<Form
{...formProps}
onFinish={async (values) => {
setIsSaving(true);
return formProps.onFinish && formProps.onFinish(values);
}}
>
・・・
エラーメッセージの表示
サーバーからのエラーレスポンスからError FormatのmessageとstatusCodeをセットすれば画面右上に通知が表示される。サーバーからのバリデーションエラーメッセージを表示したいときなどに使える
export const axiosInstance = axios.create();
・・・
axiosInstance.interceptors.response.use(
(response) => {
return response;
},
(error) => {
const customError: HttpError = {
...error,
message: error.response?.data?.message,
statusCode: error.response?.status,
};
return Promise.reject(customError);
}
);
dataProviderをカスタマイズ
外部のAPIを使う場合などリクエストやレスポンスの形式をrefineに合わせることができない場合、カスタマイズしたdataProviderを使う。Rest APIの場合はこちらをベースにカスタマイズ
例えば、一覧取得時のリクエストとレスポンスを変更したい場合のgetListを実装する。ここではページ番号のpageパラメータの追加とレコードの総数をhttpボディから取得している(オリジナルのrefineではhttpヘッダーからレコードの総数を取得しようとする)
export const restDataProvider = (
apiUrl: string,
httpClient: AxiosInstance = axiosInstance,
): DataProvider => {
const simpleRestDataProvider = dataProvider(apiUrl, httpClient);
const customRestDataProvider: DataProvider = {
...simpleRestDataProvider,
getList: async ({ resource, pagination, filters, sort }) => {
const url = `${apiUrl}/${resource}`;
// pagination const current = pagination?.current || 1;
const queryFilters = generateFilter(filters);
const query: {
page: number;
_sort?: string;
_order?: string;
} = {
page: current,
};
const generatedSort = generateSort(sort);
if (generatedSort) {
const { _sort, _order } = generatedSort;
query._sort = _sort.join(",");
query._order = _order.join(",");
}
const { data, headers } = await httpClient.get(
`${url}?${stringify(query)}&${stringify(queryFilters)}`,
);
return {
data: data.data,
total: data.total,
};
},
}
return customRestDataProvider
}
カスタマイズしたdataProviderを使用する
import { restDataProvider } from "./dataProvider";
export const axiosInstance = axios.create();
axiosInstance.defaults.baseURL = API_URL;
・・・
const App: React.FC = () => {
return (
<Refine
dataProvider={restDataProvider(API_URL, axiosInstance)}
・・・
/>
);
};
ファイルアップロード
Multipart UploadとBase64 Uploadがある。前者はファイルを選択したタイミングでアップロードする場合に、後者は他のフォーム値同様JSONなどで送信したい場合で使い分けることができる
以下はbase64形式で1ファイルアップロードする場合のサンプルで、Upload.DraggerのbeforeUploadで選択したタイミングでMultipart Uploadされないようにしている
<Create saveButtonProps={saveButtonProps}>
<Form
{...formProps}
onFinish={async (values) => {
const { upload_file } = values;
const files: IUpload[] = [];
files.push({
name: "upload_file",
base64: await file2Base64(upload_file![0]),
});
return formProps.onFinish && formProps.onFinish({ ...values, files });
}}>
<Form.Item label="ファイルアップロード">
<Form.Item
name="upload_file"
rules={[{ required: true, message: "入力してください" }]}
getValueFromEvent={getValueFromEvent}
>
<Upload.Dragger
name="upload_file"
maxCount={1}
accept=".png"
beforeUpload={() => false}
>
<p className="ant-upload-text">Drag & drop a file in this area</p>
</Upload.Dragger>
</Form.Item>
</Form.Item>
</Form>
</Create>
React-Query設定
refineではReact-Queryが使用されている。refineのreactQueryClientConfigプロパティでreact-queryの設定を変更可。例えば、デフォルトではAPIへのリクエストでエラーになるとリトライされるがそれを無効とする
<Refine
reactQueryClientConfig={{
defaultOptions: {
queries: { retry: false },
},
}}
・・・
レイアウトなど調整
例えば、詳細画面のデフォルトのレイアウトでは右上にボタンが4つ並んでる
ここでは編集ボタンだけにしたいので、pageHeaderPropsを指定する
・・・
export const PostShow = () => {
・・・
return (
<Show pageHeaderProps={{ extra: <EditButton /> }}>
・・・
</Show>
);
};
他の画面やコンポーネントにも同じようなプロパティが用意されているのである程度柔軟に対応できる
おわりに
公式サイト
中途半端なサンプルアプリ
Discussion