Amplify UI の新しい機能、Amplify Form を試してみた #reinvent

re:InventのEXPO会場のAWS Ask the ExpertsブースでAmplify Studio Form Builderのデモをやっていたので見学させていただきました。開発者の方ともお話しできてとても有意義な時間でした。
2022.12.04

re:Invent では EXPO というT シャツがたくさんもらえるところ パートナー企業のブースがたくさん出展されるイベントがあり、その中の AWS Ask the Experts のブースでは開発者と直に話したり質問したりすることができます。

Expo をうろうろしていたら Amplify のブースがあったので立ち寄ってみたところ Amplify UI と Amplify Studio Form Builder についてデモをやっていたので見学したり質問したりすることができました。

Amplify UI とは

Amplify UI は、クラウドに直接接続できるテーマ性、高パフォーマンスの React(および Vue, Angular, Flutter など)コンポーネントライブラリです。

デザイントークンとプレーン CSS を使用しているため、細部までカスタマイズ可能な Theme 機能ではアプリケーションのユニークなルック&フィールを数分で作成できます。ダークモード、レスポンシブ、ユーザー設定などのダイナミックなテーマ設定も、テーマオーバーライドで簡単に行えます。

Authenticator コンポーネントを使用すると 10 行以下のコードでアプリに認証を追加できます。Authenticator は Amplify CLI とシームレスに動作し、バックエンドと自動的に連動します。テーマやオーバーライドを使用して認証フローの詳細をカスタマイズしたり、ヘッドレスモードで独自の UI を導入したりできます。

上記の他にも Figma と連動してコンポーネントを生成してくれたりなどの機能が備わった UI ライブラリです。

Amplify Studio Form Builder

re:Invent 直前の 2022.11.21 にリリースされた新機能で、JSON で設定したい項目とバリデーション内容を設定するだけでフォームの React コンポーネントを生成してくれる優れものです。

フォームのデザインも GUI から柔軟に変更することができ、バリデーションの設定も可能です。

Amplify Form を利用するには プロジェクトに Amplify と Amplify UI をインストールする必要があります。

使ってみた

試しに GUI からフォームを生成できる Amplify Studio Form Builder をAmplify Studio Sandboxで試してみました。Sandbox環境はAWSアカウントを持っていなくても利用することができます。

JSON で要素を指定

Amplify Studio Sandboxを開いて JSON Object を指定します。

JSON を貼り付ける

以下の JSON を貼り付けて Create Form でフォーム作成を始めます。

{
  "firstName": "くらす",
  "lastName": "めそこ",
  "email": "kurasu.mesoko@classmethod.jp",
  "position": "エンジニア",
  "birthdate": "1999-10-20"
}

Form Builder 画面でフォームを作成

GUI から要素の位置変更、プレイスホルダーの設定、バリデーションの追加を行えます。サーバーサイドのデータとのマッピングもGUIから設定することができます。

React コンポーネントとしてエクスポート

GUI で作成したフォームを React コンポーネントとしてダウンロードできます。

MyForm.jsx
/* eslint-disable */
import * as React from "react";
import {fetchByPath, validateField} from "./utils";
import {getOverrideProps} from "@aws-amplify/ui-react/internal";
import {Button, Divider, Flex, Grid, TextField} from "@aws-amplify/ui-react";
export default function MyForm(props) {
  const {onSubmit, onCancel, onValidate, onChange, overrides, ...rest} = props;
  const initialValues = {
    firstName: undefined,
    lastName: undefined,
    email: undefined,
    position: undefined,
    birthdate: undefined,
  };
  const [firstName, setFirstName] = React.useState(initialValues.firstName);
  const [lastName, setLastName] = React.useState(initialValues.lastName);
  const [email, setEmail] = React.useState(initialValues.email);
  const [position, setPosition] = React.useState(initialValues.position);
  const [birthdate, setBirthdate] = React.useState(initialValues.birthdate);
  const [errors, setErrors] = React.useState({});
  const resetStateValues = () => {
    setFirstName(initialValues.firstName);
    setLastName(initialValues.lastName);
    setEmail(initialValues.email);
    setPosition(initialValues.position);
    setBirthdate(initialValues.birthdate);
    setErrors({});
  };
  const validations = {
    firstName: [
      {
        type: "GreaterThanChar",
        numValues: [1],
        validationMessage: "\u59D3\u306F\u5FC5\u9808\u9805\u76EE\u3067\u3059\n",
      },
    ],
    lastName: [
      {type: "Required"},
      {
        type: "GreaterThanChar",
        numValues: [1],
        validationMessage: "\u540D\u306F\u5FC5\u9808\u9805\u76EE\u3067\u3059",
      },
    ],
    email: [{type: "Email"}],
    position: [],
    birthdate: [{type: "Required"}],
  };
  const runValidationTasks = async (fieldName, value) => {
    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();
        const modelFields = {
          firstName,
          lastName,
          email,
          position,
          birthdate,
        };
        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;
        }
        await onSubmit(modelFields);
      }}
      {...rest}
      {...getOverrideProps(overrides, "MyForm")}
    >
      <Grid
        columnGap="inherit"
        rowGap="inherit"
        templateColumns="repeat(2, auto)"
        {...getOverrideProps(overrides, "RowGrid0")}
      >
        <TextField
          label="姓"
          isRequired={false}
          placeholder="くらす"
          onChange={(e) => {
            let {value} = e.target;
            if (onChange) {
              const modelFields = {
                firstName: value,
                lastName,
                email,
                position,
                birthdate,
              };
              const result = onChange(modelFields);
              value = result?.firstName ?? value;
            }
            if (errors.firstName?.hasError) {
              runValidationTasks("firstName", value);
            }
            setFirstName(value);
          }}
          onBlur={() => runValidationTasks("firstName", firstName)}
          errorMessage={errors.firstName?.errorMessage}
          hasError={errors.firstName?.hasError}
          {...getOverrideProps(overrides, "firstName")}
        ></TextField>
        <TextField
          label="名"
          isRequired={true}
          placeholder="めそこ"
          onChange={(e) => {
            let {value} = e.target;
            if (onChange) {
              const modelFields = {
                firstName,
                lastName: value,
                email,
                position,
                birthdate,
              };
              const result = onChange(modelFields);
              value = result?.lastName ?? value;
            }
            if (errors.lastName?.hasError) {
              runValidationTasks("lastName", value);
            }
            setLastName(value);
          }}
          onBlur={() => runValidationTasks("lastName", lastName)}
          errorMessage={errors.lastName?.errorMessage}
          hasError={errors.lastName?.hasError}
          {...getOverrideProps(overrides, "lastName")}
        ></TextField>
      </Grid>
      <Divider
        orientation="horizontal"
        {...getOverrideProps(overrides, "SectionalElement1")}
      ></Divider>
      <TextField
        label="Email"
        onChange={(e) => {
          let {value} = e.target;
          if (onChange) {
            const modelFields = {
              firstName,
              lastName,
              email: value,
              position,
              birthdate,
            };
            const result = onChange(modelFields);
            value = result?.email ?? value;
          }
          if (errors.email?.hasError) {
            runValidationTasks("email", value);
          }
          setEmail(value);
        }}
        onBlur={() => runValidationTasks("email", email)}
        errorMessage={errors.email?.errorMessage}
        hasError={errors.email?.hasError}
        {...getOverrideProps(overrides, "email")}
      ></TextField>
      <TextField
        label="肩書き"
        placeholder="マーケター"
        onChange={(e) => {
          let {value} = e.target;
          if (onChange) {
            const modelFields = {
              firstName,
              lastName,
              email,
              position: value,
              birthdate,
            };
            const result = onChange(modelFields);
            value = result?.position ?? value;
          }
          if (errors.position?.hasError) {
            runValidationTasks("position", value);
          }
          setPosition(value);
        }}
        onBlur={() => runValidationTasks("position", position)}
        errorMessage={errors.position?.errorMessage}
        hasError={errors.position?.hasError}
        {...getOverrideProps(overrides, "position")}
      ></TextField>
      <TextField
        label="誕生日"
        isRequired={true}
        placeholder="2000/01/01"
        type="date"
        onChange={(e) => {
          let {value} = e.target;
          if (onChange) {
            const modelFields = {
              firstName,
              lastName,
              email,
              position,
              birthdate: value,
            };
            const result = onChange(modelFields);
            value = result?.birthdate ?? value;
          }
          if (errors.birthdate?.hasError) {
            runValidationTasks("birthdate", value);
          }
          setBirthdate(value);
        }}
        onBlur={() => runValidationTasks("birthdate", birthdate)}
        errorMessage={errors.birthdate?.errorMessage}
        hasError={errors.birthdate?.hasError}
        {...getOverrideProps(overrides, "birthdate")}
      ></TextField>
      <Flex
        justifyContent="space-between"
        {...getOverrideProps(overrides, "CTAFlex")}
      >
        <Button
          children="Clear"
          type="reset"
          onClick={resetStateValues}
          {...getOverrideProps(overrides, "ClearButton")}
        ></Button>
        <Flex {...getOverrideProps(overrides, "RightAlignCTASubFlex")}>
          <Button
            children="Cancel"
            type="button"
            onClick={() => {
              onCancel && onCancel();
            }}
            {...getOverrideProps(overrides, "CancelButton")}
          ></Button>
          <Button
            children="Submit"
            type="submit"
            variation="primary"
            isDisabled={Object.values(errors).some((e) => e?.hasError)}
            {...getOverrideProps(overrides, "SubmitButton")}
          ></Button>
        </Flex>
      </Flex>
    </Grid>
  );
}
utils.js
/* eslint-disable */
export const validateField = (value, validations) => {
  for (const validation of validations) {
    if (value === undefined || value === "") {
      if (validation.type === "Required") {
        return {
          hasError: true,
          errorMessage: validation.validationMessage || "The value is required",
        };
      } else {
        return {
          hasError: false,
        };
      }
    }
    const validationResult = checkValidation(value, validation);
    if (validationResult?.hasError) {
      return validationResult;
    }
  }
  return {hasError: false};
};
const checkValidation = (value, validation) => {
  if (validation.numValues?.length) {
    switch (validation.type) {
      case "LessThanChar":
        return {
          hasError: !(value.length <= validation.numValues[0]),
          errorMessage:
            validation.validationMessage ||
            `The value must be shorter than ${validation.numValues[0]}`,
        };
      case "GreaterThanChar":
        return {
          hasError: !(value.length > validation.numValues[0]),
          errorMessage:
            validation.validationMessage ||
            `The value must be longer than ${validation.numValues[0]}`,
        };
      case "LessThanNum":
        return {
          hasError: !(value < validation.numValues[0]),
          errorMessage:
            validation.validationMessage ||
            `The value must be less than ${validation.numValues[0]}`,
        };
      case "GreaterThanNum":
        return {
          hasError: !(value > validation.numValues[0]),
          errorMessage:
            validation.validationMessage ||
            `The value must be greater than ${validation.numValues[0]}`,
        };
      case "EqualTo":
        return {
          hasError: !validation.numValues.some((el) => el === value),
          errorMessage:
            validation.validationMessage ||
            `The value must be equal to ${validation.numValues.join(" or ")}`,
        };
      default:
    }
  } else if (validation.strValues?.length) {
    switch (validation.type) {
      case "StartWith":
        return {
          hasError: !validation.strValues.some((el) => value.startsWith(el)),
          errorMessage:
            validation.validationMessage ||
            `The value must start with ${validation.strValues.join(", ")}`,
        };
      case "EndWith":
        return {
          hasError: !validation.strValues.some((el) => value.endsWith(el)),
          errorMessage:
            validation.validationMessage ||
            `The value must end with ${validation.strValues.join(", ")}`,
        };
      case "Contains":
        return {
          hasError: !validation.strValues.some((el) => value.includes(el)),
          errorMessage:
            validation.validationMessage ||
            `The value must contain ${validation.strValues.join(", ")}`,
        };
      case "NotContains":
        return {
          hasError: !validation.strValues.every((el) => !value.includes(el)),
          errorMessage:
            validation.validationMessage ||
            `The value must not contain ${validation.strValues.join(", ")}`,
        };
      case "BeAfter":
        const afterTimeValue = parseInt(validation.strValues[0]);
        const afterTimeValidator = Number.isNaN(afterTimeValue)
          ? validation.strValues[0]
          : afterTimeValue;
        return {
          hasError: !(new Date(value) > new Date(afterTimeValidator)),
          errorMessage:
            validation.validationMessage ||
            `The value must be after ${validation.strValues[0]}`,
        };
      case "BeBefore":
        const beforeTimeValue = parseInt(validation.strValues[0]);
        const beforeTimevalue = Number.isNaN(beforeTimeValue)
          ? validation.strValues[0]
          : beforeTimeValue;
        return {
          hasError: !(new Date(value) < new Date(beforeTimevalue)),
          errorMessage:
            validation.validationMessage ||
            `The value must be before ${validation.strValues[0]}`,
        };
    }
  }
  switch (validation.type) {
    case "Email":
      const EMAIL_ADDRESS_REGEX =
        /^[-!#$%&'*+\/0-9=?A-Z^_a-z`{|}~](\.?[-!#$%&'*+\/0-9=?A-Z^_a-z`{|}~])*@[a-zA-Z0-9](-*\.?[a-zA-Z0-9])*\.[a-zA-Z](-?[a-zA-Z0-9])+$/;
      return {
        hasError: !EMAIL_ADDRESS_REGEX.test(value),
        errorMessage:
          validation.validationMessage ||
          "The value must be a valid email address",
      };
    case "JSON":
      let isInvalidJSON = false;
      try {
        JSON.parse(value);
      } catch (e) {
        isInvalidJSON = true;
      }
      return {
        hasError: isInvalidJSON,
        errorMessage:
          validation.validationMessage ||
          "The value must be in a correct JSON format",
      };
    case "IpAddress":
      const IPV_4 =
        /^(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}$/;
      const IPV_6 =
        /^(?:(?:[a-fA-F\d]{1,4}:){7}(?:[a-fA-F\d]{1,4}|:)|(?:[a-fA-F\d]{1,4}:){6}(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|:[a-fA-F\d]{1,4}|:)|(?:[a-fA-F\d]{1,4}:){5}(?::(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-fA-F\d]{1,4}){1,2}|:)|(?:[a-fA-F\d]{1,4}:){4}(?:(?::[a-fA-F\d]{1,4}){0,1}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-fA-F\d]{1,4}){1,3}|:)|(?:[a-fA-F\d]{1,4}:){3}(?:(?::[a-fA-F\d]{1,4}){0,2}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-fA-F\d]{1,4}){1,4}|:)|(?:[a-fA-F\d]{1,4}:){2}(?:(?::[a-fA-F\d]{1,4}){0,3}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-fA-F\d]{1,4}){1,5}|:)|(?:[a-fA-F\d]{1,4}:){1}(?:(?::[a-fA-F\d]{1,4}){0,4}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-fA-F\d]{1,4}){1,6}|:)|(?::(?:(?::[a-fA-F\d]{1,4}){0,5}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-fA-F\d]{1,4}){1,7}|:)))(?:%[0-9a-zA-Z]{1,})?$/;
      return {
        hasError: !(IPV_4.test(value) || IPV_6.test(value)),
        errorMessage:
          validation.validationMessage ||
          "The value must be an IPv4 or IPv6 address",
      };
    case "URL":
      let isInvalidUrl = false;
      try {
        new URL(value);
      } catch (e) {
        isInvalidUrl = true;
      }
      return {
        hasError: isInvalidUrl,
        errorMessage:
          validation.validationMessage ||
          "The value must be a valid URL that begins with a schema (i.e. http:// or mailto:)",
      };
    case "Phone":
      const PHONE = /^\+?\d[\d\s-]+$/;
      return {
        hasError: !PHONE.test(value),
        errorMessage:
          validation.validationMessage ||
          "The value must be a valid phone number",
      };
    default:
  }
};
export const fetchByPath = (input, path = "", accumlator = []) => {
  const currentPath = path.split(".");
  const head = currentPath.shift();
  if (input && head && input[head] !== undefined) {
    if (!currentPath.length) {
      accumlator.push(input[head]);
    } else {
      fetchByPath(input[head], currentPath.join("."), accumlator);
    }
  }
  return accumlator[0];
};

バリデーションの内容は util.js に吐き出されるので GUI で変更できなかった文言等はコードを直接変更すれば好きなものに設定できそうでした。util のバリデーションはライブラリを使っておらず正規表現を使ったシンプルなものになっていますが、Zod など好みのライブラリの導入もできそうでした。

新規 React プロジェクトの作成と必要なライブラリのインストール手順は表示の通りです。

所感

真面目に作ると地味に時間がかかってしまうフォームの実装が GUI から一瞬で完成してしまうお手軽さがすごいサービスでした。さっとプロトタイプを作りたい時などぜひ利用してみたいです。Amplify UI ライブラリも使いやすそうなコンポーネントが一通り揃っている印象でしたので今度触ってみてまた記事にまとめたいと思います。

Amplify のブースでは T シャツとステッカーをいただきました

References