generateされないcustom属性のフォームの書き方

Amplifyはフォームを勝手に作ってくれるnpx ampx generate formsコマンドがめちゃくちゃ便利です。

データを定義して、npx ampx generate formsを実行するだけで、いい感じの入力フォームが勝手に作られます。

そんな便利なnpx ampx generate formsコマンドですが、残念なのが、カスタムタイプを作ってくれないことです。

そこでこの記事では、generateform で作成したコンポーネントにカスタムタイプの入力フォームを付け足す方法を紹介します。


# 対象読者
想定しているのは、webサービスをさくっとつくりたくて、Amplifyを勉強しはじめた人を対象としています。
私も簡単にwebサービスつくりたくてプログラミング勉強しはじめて、個人開発だからなんでもかんでもやる必要があって、インフラ、バックエンド回りの手間を省けないかなといろいろ探した結果Amplifyにたどり着きましたので、それほど詳しくありません。
そもそも職業エンジニアではないので。
TypeScriptに詳しい人はコードみれば、何してるかわかると思うので、あまり意味ないかもしれません。

# 簡単な自己紹介
webサービスをつくりたくて、プログラミングを勉強しはじめました。普段は機械メーカーの営業をしていますので、本職のエンジニアほど詳しくないと思います。

せっかくアドヴェントカレンダーで書くので、私の個人開発したwebサービスの宣伝を。

製造業向けのバーチャル展示会サービスを運営しております。

https://vexpo.jp/

# サービス開発の背景

一度開催してお役御免となる展示品を有効活用するためのプラットフォームです。バーチャル展示会でしたら、ユーザーのインタラクションも取得できるため、そこからユーザーの興味、関心に基づく提案もできるため営業活動の助けにもなると思います。
BtoBのサービス且つ、製造業向けというかなりユーザーを限定したサービスなので、関わりある人がどれくらいいるかですが、もし興味があれば。

このサービスを作った動機は、簡潔にいうと２つです。
・展示会を開催するのに、準備からフォローまでとてもコストがかかるのに、１回きりになるのはもったいない
・もっと自分の営業活動を効率化して楽をしたかった。

この２点です。まだはじまったばかりなので、どれくらい広まるかわかりませんが暖かく見守ってください。
蛇足ですが、こちらのサービスもAmplifyで作りました。

さて、本題に戻ります。

カスタム属性の作り方に入る前npx ampx generate formsで生成されるフォームに関しては、こちらを参照してください。
https://docs.amplify.aws/react/build-ui/formbuilder/

とりあえず、dataの構造はこんな感じにしておきます。

```
DummyData:a
      .model({
      name: a.string(),
      // fields can be of custom types
      location: a.customType({
        // fields can be required or optional
        lat: a.float().required(),
        long: a.float().required(),
      }),
      // fields can be enums
      engagementStage: a.enum(["PROSPECT", "INTERESTED", "PURCHASED"]),
      })
      .authorization(allow => [
        allow.authenticated(),
      ]),
```

とりあえず、こんな感じのモデルにしましょう。

そして、フォームを作成するとこんな感じにフォームが出来上がります。

```

/* eslint-disable */
"use client";
import * as React from "react";
import {
  Button,
  Flex,
  Grid,
  SelectField,
  TextField,
} from "@aws-amplify/ui-react";
import { fetchByPath, getOverrideProps, validateField } from "./utils";
import { generateClient } from "aws-amplify/api";
import { createDummyData } from "./graphql/mutations";
const client = generateClient();
export default function DummyDataCreateForm(props) {
  const {
    clearOnSuccess = true,
    onSuccess,
    onError,
    onSubmit,
    onValidate,
    onChange,
    overrides,
    ...rest
  } = props;
  const initialValues = {
    name: "",
    engagementStage: "",
  };
  const [name, setName] = React.useState(initialValues.name);
  const [engagementStage, setEngagementStage] = React.useState(
    initialValues.engagementStage
  );
  const [errors, setErrors] = React.useState({});
  const resetStateValues = () => {
    setName(initialValues.name);
    setEngagementStage(initialValues.engagementStage);
    setErrors({});
  };
  const validations = {
    name: [],
    engagementStage: [],
  };
  const runValidationTasks = async (
    fieldName,
    currentValue,
    getDisplayValue
  ) => {
    const value =
      currentValue && getDisplayValue
        ? getDisplayValue(currentValue)
        : currentValue;
    let validationResponse = validateField(value, validations[fieldName]);
    const customValidator = fetchByPath(onValidate, fieldName);
    if (customValidator) {
      validationResponse = await customValidator(value, validationResponse);
    }
    setErrors((errors) => ({ ...errors, [fieldName]: validationResponse }));
    return validationResponse;
  };
  return (
    <Grid
      as="form"
      rowGap="15px"
      columnGap="15px"
      padding="20px"
      onSubmit={async (event) => {
        event.preventDefault();
        let modelFields = {
          name,
          engagementStage,
        };
        const validationResponses = await Promise.all(
          Object.keys(validations).reduce((promises, fieldName) => {
            if (Array.isArray(modelFields[fieldName])) {
              promises.push(
                ...modelFields[fieldName].map((item) =>
                  runValidationTasks(fieldName, item)
                )
              );
              return promises;
            }
            promises.push(
              runValidationTasks(fieldName, modelFields[fieldName])
            );
            return promises;
          }, [])
        );
        if (validationResponses.some((r) => r.hasError)) {
          return;
        }
        if (onSubmit) {
          modelFields = onSubmit(modelFields);
        }
        try {
          Object.entries(modelFields).forEach(([key, value]) => {
            if (typeof value === "string" && value === "") {
              modelFields[key] = null;
            }
          });
          await client.graphql({
            query: createDummyData.replaceAll("__typename", ""),
            variables: {
              input: {
                ...modelFields,
              },
            },
          });
          if (onSuccess) {
            onSuccess(modelFields);
          }
          if (clearOnSuccess) {
            resetStateValues();
          }
        } catch (err) {
          if (onError) {
            const messages = err.errors.map((e) => e.message).join("\n");
            onError(modelFields, messages);
          }
        }
      }}
      {...getOverrideProps(overrides, "DummyDataCreateForm")}
      {...rest}
    >
      <TextField
        label="Name"
        isRequired={false}
        isReadOnly={false}
        value={name}
        onChange={(e) => {
          let { value } = e.target;
          if (onChange) {
            const modelFields = {
              name: value,
              engagementStage,
            };
            const result = onChange(modelFields);
            value = result?.name ?? value;
          }
          if (errors.name?.hasError) {
            runValidationTasks("name", value);
          }
          setName(value);
        }}
        onBlur={() => runValidationTasks("name", name)}
        errorMessage={errors.name?.errorMessage}
        hasError={errors.name?.hasError}
        {...getOverrideProps(overrides, "name")}
      ></TextField>
      <SelectField
        label="Engagement stage"
        placeholder="Please select an option"
        isDisabled={false}
        value={engagementStage}
        onChange={(e) => {
          let { value } = e.target;
          if (onChange) {
            const modelFields = {
              name,
              engagementStage: value,
            };
            const result = onChange(modelFields);
            value = result?.engagementStage ?? value;
          }
          if (errors.engagementStage?.hasError) {
            runValidationTasks("engagementStage", value);
          }
          setEngagementStage(value);
        }}
        onBlur={() => runValidationTasks("engagementStage", engagementStage)}
        errorMessage={errors.engagementStage?.errorMessage}
        hasError={errors.engagementStage?.hasError}
        {...getOverrideProps(overrides, "engagementStage")}
      >
        <option
          children="Prospect"
          value="PROSPECT"
          {...getOverrideProps(overrides, "engagementStageoption0")}
        ></option>
        <option
          children="Interested"
          value="INTERESTED"
          {...getOverrideProps(overrides, "engagementStageoption1")}
        ></option>
        <option
          children="Purchased"
          value="PURCHASED"
          {...getOverrideProps(overrides, "engagementStageoption2")}
        ></option>
      </SelectField>
      <Flex
        justifyContent="space-between"
        {...getOverrideProps(overrides, "CTAFlex")}
      >
        <Button
          children="Clear"
          type="reset"
          onClick={(event) => {
            event.preventDefault();
            resetStateValues();
          }}
          {...getOverrideProps(overrides, "ClearButton")}
        ></Button>
        <Flex
          gap="15px"
          {...getOverrideProps(overrides, "RightAlignCTASubFlex")}
        >
          <Button
            children="Submit"
            type="submit"
            variation="primary"
            isDisabled={Object.values(errors).some((e) => e?.hasError)}
            {...getOverrideProps(overrides, "SubmitButton")}
          ></Button>
        </Flex>
      </Flex>
    </Grid>
  );
}

```

この単純なデータと一行のコマンドで結構複雑なコンポーネントが作成されています。

ただ、カスタム属性のフォームが用意されておりません。
サポートに確認しましたが、カスタム属性に関しては、npx ampx generate formsのコマンドでは、フォームを作成してくれないそうなので、自前で作るしかありません。それか、カスタム属性を使うことをあきらめるか。

カスタム属性のフォームの作り方を紹介する前に、そもそも、このフォームの構造を見てみましょう。

useState: フォームフィールド (name と engagementStage) の状態管理を行います。

resetStateValues: フォームの入力値を初期状態にリセットする関数。

validations: 各フィールドに対するバリデーションルールの設定。現時点では空の配列になっています。
ここは、モデルにrequiredとかつけると、必須項目にしてくれます。

runValidationTasks: 指定したフィールドに対してバリデーションを実行する関数。

onSubmit：modelFieldsを使ったデータの書き込み

# npx ampx generate formsがやってくれること

状態管理（初期値の設定）
バリデーション
フォームの整形
データの書き込み

なので、カスタムタイプ用にそれぞれ付け足していけばいいだけといえばだけなんです。

それではどんな感じに付け足していくかやってみましょう。

```
const initialValues = {
    name: "",
    engagementStage: "",
    location: { lat: "", long: "" },
  };
  const [name, setName] = React.useState(initialValues.name);
  const [engagementStage, setEngagementStage] = React.useState(
    initialValues.engagementStage
  );
  const [lat, setLat] = React.useState(initialValues.location.lat);
  const [long, setLong] = React.useState(initialValues.location.long);
  const [errors, setErrors] = React.useState({});
  const resetStateValues = () => {
    setName(initialValues.name);
    setEngagementStage(initialValues.engagementStage);
    setLat(initialValues.location.lat);
    setLong(initialValues.location.long);
    setErrors({});
  };
```

これで状態管理と、初期値の設定とresetStateValuesの設定ができます。

```
const validations = {
    name: [],
    engagementStage: [],
    lat: [{ type: "required", message: "Latitude is required" }],
    long: [{ type: "required", message: "Longitude is required" }],
  };
```

requiredをつけているので、バリデーションの条件はこんな感じでやってあげましょう。


```
<TextField
        label="Latitude"
        value={lat}
        onChange={(e) => setLat(e.target.value)}
        onBlur={() => runValidationTasks("lat", lat)}
        errorMessage={errors.lat?.errorMessage}
        hasError={errors.lat?.hasError}
        {...getOverrideProps(overrides, "lat")}
/>
<TextField
        label="Longitude"
        value={long}
        onChange={(e) => setLong(e.target.value)}
        onBlur={() => runValidationTasks("long", long)}
        errorMessage={errors.long?.errorMessage}
        hasError={errors.long?.hasError}
        {...getOverrideProps(overrides, "long")}
/>
```

フォームはこんな感じで整形してあげます。

そして、

onSubmitのmodelFieldsのところを

```
let modelFields = {
          name,
          engagementStage,
          location: { lat: parseFloat(lat), long: parseFloat(long) },
};
```

こんな感じで付け足せば完成です。

大量のコードが生成されるので、慣れていないと尻込みしますが、
やっていることは大したことないので、一個一個分解しながら理解すれば、
それほど複雑ではないと思います。

Amplifyで快適な開発体験を一緒に楽しみましょう