👋

React refineでサクッと管理画面を作る

2022/05/26に公開

https://refine.dev/docs/comparison/
Next.js/SSR Support
Supported UI Frameworks Ant Design, Material UI, Tailwind, anything...
・・・

refineはReactベースの新しめのフレームワークです。チュートリアルを終えると基本的なCRUDな画面が出来上がるのでこれに手を加えていく

axiosセットアップ

Rest APIでのデータ取得にSimple REST APIで使用されているaxiosのセットアップを行い、dataProviderにそれを指定。ここではbaseURLをセットしているが、withcredentialsやインターセプターの記述などが書ける

App.tsx
・・・
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からのレスポンスでエラーが返ってきたときに認証エラーかどうかの判定方法を書く

authProvider.ts
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();
  },
};
App.tsx
・・・
import { authProvider } from "authProvider";

function App() {
    ・・・
  return (
    <Refine
      authProvider={authProvider}
      ・・・

検索フォーム

Table Searchで検索フォームを追加する

タイトルのテキストフォームを追加。useTableのsyncWithLocation=trueを指定することで、検索条件とURLが同期される。また、FormコンポーネントのinitialValuesの指定でフォームのデフォルト値がセットされる。これでブラウザバックやリロードされてもフォームに初期値がセットされる

pages/posts/list.tsx
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指定でフォームのクリアーボタンを表示している

pages/posts/list.tsx
・・・
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.ts
i18n
  .use(Backend)
  .use(detector)
  .use(initReactI18next)
  .init({
    supportedLngs: ["ja"],
    backend: {
      loadPath: "/locales/{{lng}}/{{ns}}.json",
    },
    defaultNS: "common",
    fallbackLng: ["ja"],
  });
App.tsx
・・・
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フォルダを作成。英語の文字列リソースを必要に応じて日本語に変えていく

public/locales/js/common.json
{
  "pages": {
    "login": {
      "signin": "ログイン",
    },
    ・・・
  "posts": {
    "posts": "投稿",
    "fields": {
      "title": "タイトル",
      ・・・
  },

また、各ページで文字列リソースを使う

pages/posts/list.tsx
・・・
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のバリデーションルールが指定できる

src/pages/posts/create.tsx
<Form.Item
  label="title"
 name="title"
 rules={[{ required: true, message: "入力してください" }]}
>
    <Input />
</Form.Item>

Custom Inputs

Custom Inputsに書かれている。Ant Designのカスタムフォームの説明にあるように、valueとonChangeプロパティを実装したコンポーネントを作成

pages/posts/custom-input.tsx
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>
    </>
  );
};

他のフォームアイテムと同じようにバリデーションルールも使える

pages/posts/create.tsx
export const PostCreate = () => {
  ・・・
  return(
    <Form.Item
      label="category"
      name={["category", "id"]}
      rules={[{ required: true, message: "入力してください" }]}
    >
      <CategoryForm />
    </Form.Item>
  );
}

スピナーの表示

登録や更新中にボタンにスピナーを表示する

pages/posts/create.tsx
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をセットすれば画面右上に通知が表示される。サーバーからのバリデーションエラーメッセージを表示したいときなどに使える

App.tsx
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ヘッダーからレコードの総数を取得しようとする)

dataProvider.ts
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を使用する

App.tsx
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 UploadBase64 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へのリクエストでエラーになるとリトライされるがそれを無効とする

App.tsx
<Refine
  reactQueryClientConfig={{
    defaultOptions: {
      queries: { retry: false },
    },
  }}
  ・・・

レイアウトなど調整

例えば、詳細画面のデフォルトのレイアウトでは右上にボタンが4つ並んでる

ここでは編集ボタンだけにしたいので、pageHeaderPropsを指定する

pages/posts/show.tsx
・・・
export const PostShow = () => {
  ・・・
  return (
    <Show pageHeaderProps={{ extra: <EditButton /> }}>
      ・・・
    </Show>
  );
};

他の画面やコンポーネントにも同じようなプロパティが用意されているのである程度柔軟に対応できる

おわりに

公式サイト
https://refine.dev/

中途半端なサンプルアプリ
https://github.com/nrikiji/refine-admin-example

Discussion