From 3a3e13c61b773dd51be237d14106095357020e1a Mon Sep 17 00:00:00 2001 From: swaysway <7465495+SwaySway@users.noreply.github.com> Date: Fri, 2 Sep 2022 23:21:16 +0000 Subject: [PATCH] feat: add support for nested json --- ...studio-ui-codegen-react-forms.test.ts.snap | 453 +++++++++++++----- .../__utils__/amplify-renderer-generator.ts | 20 +- .../__snapshots__/form-state.test.ts.snap | 9 + .../__snapshots__/type-helper.test.ts.snap | 25 + .../lib/__tests__/forms/form-state.test.ts | 59 +++ .../lib/__tests__/forms/type-helper.test.ts | 61 +++ .../form-renderer-helper.test.ts.snap | 8 +- .../studio-ui-codegen-react-forms.test.ts | 6 + .../lib/__tests__/utils/fetch-by-path.test.ts | 39 ++ .../lib/amplify-ui-renderers/form.ts | 4 +- .../lib/forms/form-renderer-helper.ts | 382 +++++---------- .../codegen-ui-react/lib/forms/form-state.ts | 174 +++++++ .../lib/forms/react-form-renderer.ts | 58 ++- .../codegen-ui-react/lib/forms/type-helper.ts | 215 +++++++++ .../lib/forms/typescript-type-map.ts | 1 + .../lib/imports/import-mapping.ts | 2 + packages/codegen-ui-react/lib/index.ts | 1 + .../lib/react-component-renderer.ts | 2 +- .../react-utils-studio-template-renderer.ts | 5 +- .../lib/utils/json-path-fetch.ts | 232 +++++++++ packages/codegen-ui-react/package.json | 6 + .../forms/bio-nested-create.json | 59 +++ .../lib/types/form/form-metadata.ts | 1 - .../lib/utils/form-component-metadata.ts | 2 +- 24 files changed, 1399 insertions(+), 425 deletions(-) create mode 100644 packages/codegen-ui-react/lib/__tests__/forms/__snapshots__/form-state.test.ts.snap create mode 100644 packages/codegen-ui-react/lib/__tests__/forms/__snapshots__/type-helper.test.ts.snap create mode 100644 packages/codegen-ui-react/lib/__tests__/forms/form-state.test.ts create mode 100644 packages/codegen-ui-react/lib/__tests__/forms/type-helper.test.ts create mode 100644 packages/codegen-ui-react/lib/__tests__/utils/fetch-by-path.test.ts create mode 100644 packages/codegen-ui-react/lib/forms/form-state.ts create mode 100644 packages/codegen-ui-react/lib/forms/type-helper.ts create mode 100644 packages/codegen-ui-react/lib/utils/json-path-fetch.ts create mode 100644 packages/codegen-ui/example-schemas/forms/bio-nested-create.json diff --git a/packages/codegen-ui-react/lib/__tests__/__snapshots__/studio-ui-codegen-react-forms.test.ts.snap b/packages/codegen-ui-react/lib/__tests__/__snapshots__/studio-ui-codegen-react-forms.test.ts.snap index f5f77b0da..3caff2caf 100644 --- a/packages/codegen-ui-react/lib/__tests__/__snapshots__/studio-ui-codegen-react-forms.test.ts.snap +++ b/packages/codegen-ui-react/lib/__tests__/__snapshots__/studio-ui-codegen-react-forms.test.ts.snap @@ -3,11 +3,8 @@ exports[`amplify form renderer tests custom form tests should render a custom backed form 1`] = ` "/* eslint-disable */ import * as React from \\"react\\"; -import { - getOverrideProps, - useStateMutationAction, -} from \\"@aws-amplify/ui-react/internal\\"; import { validateField } from \\"./utils\\"; +import { getOverrideProps } from \\"@aws-amplify/ui-react/internal\\"; import { Button, Flex, @@ -25,8 +22,7 @@ export default function CustomDataForm(props) { overrides, ...rest } = props; - const [modelFields, setModelFields] = useStateMutationAction({}); - const [errors, setErrors] = useStateMutationAction({}); + const [errors, setErrors] = React.useState({}); const validations = { name: [{ type: \\"Required\\" }], email: [{ type: \\"Required\\" }], @@ -35,11 +31,9 @@ export default function CustomDataForm(props) { }; const runValidationTasks = async (fieldName, value) => { let validationResponse = validateField(value, validations[fieldName]); - if (onValidate?.[fieldName]) { - validationResponse = await onValidate[fieldName]( - value, - validationResponse - ); + const customValidator = fetchByPath(onValidate, fieldName); + if (customValidator) { + validationResponse = await customValidator(value, validationResponse); } setErrors((errors) => ({ ...errors, [fieldName]: validationResponse })); return validationResponse; @@ -48,6 +42,12 @@ export default function CustomDataForm(props) {
{ event.preventDefault(); + const modelFields = { + name, + email, + city, + category, + }; const validationResponses = await Promise.all( Object.keys(validations).map((fieldName) => runValidationTasks(fieldName, modelFields[fieldName]) @@ -80,7 +80,7 @@ export default function CustomDataForm(props) { onChange={async (e) => { const { value } = e.target; await runValidationTasks(\\"name\\", value); - setModelFields({ ...modelFields, name: value }); + setName(value); }} errorMessage={errors.name?.errorMessage} hasError={errors.name?.hasError} @@ -100,7 +100,7 @@ export default function CustomDataForm(props) { onChange={async (e) => { const { value } = e.target; await runValidationTasks(\\"email\\", value); - setModelFields({ ...modelFields, email: value }); + setEmail(value); }} errorMessage={errors.email?.errorMessage} hasError={errors.email?.hasError} @@ -118,7 +118,7 @@ export default function CustomDataForm(props) { onChange={async (e) => { const { value } = e.target; await runValidationTasks(\\"city\\", value); - setModelFields({ ...modelFields, city: value }); + setCity(value); }} errorMessage={errors.city?.errorMessage} hasError={errors.city?.hasError} @@ -155,7 +155,7 @@ export default function CustomDataForm(props) { onChange={async (e) => { const { value } = e.target; await runValidationTasks(\\"category\\", value); - setModelFields({ ...modelFields, category: value }); + setCategory(value); }} errorMessage={errors.category?.errorMessage} hasError={errors.category?.hasError} @@ -221,11 +221,12 @@ export declare type ValidationResponse = { hasError: boolean; errorMessage?: string; }; +export declare type ValidationFunction = (value: T, validationResponse: ValidationResponse) => ValidationResponse | Promise; export declare type CustomDataFormInputValues = { - name: string; - email: string; - city: string; - category: string; + name?: ValidationFunction; + email?: ValidationFunction; + city?: ValidationFunction; + category?: ValidationFunction; }; export declare type CustomDataFormOverridesProps = { CustomDataFormGrid?: GridProps; @@ -243,22 +244,235 @@ export declare type CustomDataFormProps = React.PropsWithChildren<{ } & { onSubmit: (fields: Record) => void; onCancel?: () => void; - onValidate?: { - [field in keyof CustomDataFormInputValues]?: (value: CustomDataFormInputValues[field], validationResponse: ValidationResponse) => ValidationResponse | Promise; - }; + onValidate?: CustomDataFormInputValues; }>; export default function CustomDataForm(props: CustomDataFormProps): React.ReactElement; " `; +exports[`amplify form renderer tests custom form tests should render nested json fields 1`] = ` +"/* eslint-disable */ +import * as React from \\"react\\"; +import { validateField } from \\"./utils\\"; +import { getOverrideProps } from \\"@aws-amplify/ui-react/internal\\"; +import { Button, Flex, Grid, Heading, TextField } from \\"@aws-amplify/ui-react\\"; +export default function NestedJson(props) { + const { + onSubmit: nestedJsonOnSubmit, + onCancel, + onValidate, + overrides, + ...rest + } = props; + const [errors, setErrors] = React.useState({}); + const validations = { + firstName: [], + lastName: [], + \\"bio.favoriteQuote\\": [], + \\"bio.favoriteAnimal\\": [], + }; + 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 ( + { + event.preventDefault(); + const modelFields = { + firstName, + lastName, + bio, + }; + const validationResponses = await Promise.all( + Object.keys(validations).map((fieldName) => + runValidationTasks(fieldName, modelFields[fieldName]) + ) + ); + if (validationResponses.some((r) => r.hasError)) { + return; + } + await nestedJsonOnSubmit(modelFields); + }} + {...rest} + {...getOverrideProps(overrides, \\"NestedJson\\")} + > + + + { + const { value } = e.target; + await runValidationTasks(\\"firstName\\", value); + setFirstName(value); + }} + errorMessage={errors.firstName?.errorMessage} + hasError={errors.firstName?.hasError} + {...getOverrideProps(overrides, \\"firstName\\")} + > + + + { + const { value } = e.target; + await runValidationTasks(\\"lastName\\", value); + setLastName(value); + }} + errorMessage={errors.lastName?.errorMessage} + hasError={errors.lastName?.hasError} + {...getOverrideProps(overrides, \\"lastName\\")} + > + + + + + + { + const { value } = e.target; + await runValidationTasks(\\"bio.favoriteQuote\\", value); + setBio({ ...bio, favoriteQuote: value }); + }} + errorMessage={errors[\\"bio.favoriteQuote\\"]?.errorMessage} + hasError={errors[\\"bio.favoriteQuote\\"]?.hasError} + {...getOverrideProps(overrides, \\"bio.favoriteQuote\\")} + > + + + { + const { value } = e.target; + await runValidationTasks(\\"bio.favoriteAnimal\\", value); + setBio({ ...bio, favoriteAnimal: value }); + }} + errorMessage={errors[\\"bio.favoriteAnimal\\"]?.errorMessage} + hasError={errors[\\"bio.favoriteAnimal\\"]?.hasError} + {...getOverrideProps(overrides, \\"bio.favoriteAnimal\\")} + > + + + + + + + + + + + ); +} +" +`; + +exports[`amplify form renderer tests custom form tests should render nested json fields 2`] = ` +"import * as React from \\"react\\"; +import { EscapeHatchProps } from \\"@aws-amplify/ui-react/internal\\"; +import { GridProps, HeadingProps, TextFieldProps } from \\"@aws-amplify/ui-react\\"; +export declare type ValidationResponse = { + hasError: boolean; + errorMessage?: string; +}; +export declare type ValidationFunction = (value: T, validationResponse: ValidationResponse) => ValidationResponse | Promise; +export declare type NestedJsonInputValues = { + firstName?: ValidationFunction; + lastName?: ValidationFunction; + bio: { + favoriteQuote: ValidationFunction; + favoriteAnimal: ValidationFunction; + }; +}; +export declare type NestedJsonOverridesProps = { + NestedJsonGrid?: GridProps; + RowGrid0?: GridProps; + firstName?: TextFieldProps; + RowGrid1?: GridProps; + lastName?: TextFieldProps; + RowGrid2?: GridProps; + bio?: HeadingProps; + RowGrid3?: GridProps; + \\"bio.favoriteQuote\\"?: TextFieldProps; + RowGrid4?: GridProps; + \\"bio.favoriteAnimal\\"?: TextFieldProps; +} & EscapeHatchProps; +export declare type NestedJsonProps = React.PropsWithChildren<{ + overrides?: NestedJsonOverridesProps | undefined | null; +} & { + onSubmit: (fields: Record) => void; + onCancel?: () => void; + onValidate?: NestedJsonInputValues; +}>; +export default function NestedJson(props: NestedJsonProps): React.ReactElement; +" +`; + exports[`amplify form renderer tests custom form tests should render sectional elements 1`] = ` "/* eslint-disable */ import * as React from \\"react\\"; -import { - getOverrideProps, - useStateMutationAction, -} from \\"@aws-amplify/ui-react/internal\\"; import { validateField } from \\"./utils\\"; +import { getOverrideProps } from \\"@aws-amplify/ui-react/internal\\"; import { Button, Divider, @@ -276,18 +490,15 @@ export default function CustomWithSectionalElements(props) { overrides, ...rest } = props; - const [modelFields, setModelFields] = useStateMutationAction({}); - const [errors, setErrors] = useStateMutationAction({}); + const [errors, setErrors] = React.useState({}); const validations = { name: [], }; const runValidationTasks = async (fieldName, value) => { let validationResponse = validateField(value, validations[fieldName]); - if (onValidate?.[fieldName]) { - validationResponse = await onValidate[fieldName]( - value, - validationResponse - ); + const customValidator = fetchByPath(onValidate, fieldName); + if (customValidator) { + validationResponse = await customValidator(value, validationResponse); } setErrors((errors) => ({ ...errors, [fieldName]: validationResponse })); return validationResponse; @@ -296,6 +507,9 @@ export default function CustomWithSectionalElements(props) {
{ event.preventDefault(); + const modelFields = { + name, + }; const validationResponses = await Promise.all( Object.keys(validations).map((fieldName) => runValidationTasks(fieldName, modelFields[fieldName]) @@ -338,7 +552,7 @@ export default function CustomWithSectionalElements(props) { onChange={async (e) => { const { value } = e.target; await runValidationTasks(\\"name\\", value); - setModelFields({ ...modelFields, name: value }); + setName(value); }} errorMessage={errors.name?.errorMessage} hasError={errors.name?.hasError} @@ -410,8 +624,9 @@ export declare type ValidationResponse = { hasError: boolean; errorMessage?: string; }; +export declare type ValidationFunction = (value: T, validationResponse: ValidationResponse) => ValidationResponse | Promise; export declare type CustomWithSectionalElementsInputValues = { - name: string; + name?: ValidationFunction; }; export declare type CustomWithSectionalElementsOverridesProps = { CustomWithSectionalElementsGrid?: GridProps; @@ -429,9 +644,7 @@ export declare type CustomWithSectionalElementsProps = React.PropsWithChildren<{ } & { onSubmit: (fields: Record) => void; onCancel?: () => void; - onValidate?: { - [field in keyof CustomWithSectionalElementsInputValues]?: (value: CustomWithSectionalElementsInputValues[field], validationResponse: ValidationResponse) => ValidationResponse | Promise; - }; + onValidate?: CustomWithSectionalElementsInputValues; }>; export default function CustomWithSectionalElements(props: CustomWithSectionalElementsProps): React.ReactElement; " @@ -440,12 +653,9 @@ export default function CustomWithSectionalElements(props: CustomWithSectionalEl exports[`amplify form renderer tests datastore form tests should generate a create form 1`] = ` "/* eslint-disable */ import * as React from \\"react\\"; -import { - getOverrideProps, - useStateMutationAction, -} from \\"@aws-amplify/ui-react/internal\\"; -import { Post } from \\"../models\\"; import { validateField } from \\"./utils\\"; +import { Post } from \\"../models\\"; +import { getOverrideProps } from \\"@aws-amplify/ui-react/internal\\"; import { Button, Flex, Grid, TextField } from \\"@aws-amplify/ui-react\\"; import { DataStore } from \\"aws-amplify\\"; export default function MyPostForm(props) { @@ -457,8 +667,7 @@ export default function MyPostForm(props) { overrides, ...rest } = props; - const [modelFields, setModelFields] = useStateMutationAction({}); - const [errors, setErrors] = useStateMutationAction({}); + const [errors, setErrors] = React.useState({}); const validations = { caption: [], username: [], @@ -467,11 +676,9 @@ export default function MyPostForm(props) { }; const runValidationTasks = async (fieldName, value) => { let validationResponse = validateField(value, validations[fieldName]); - if (onValidate?.[fieldName]) { - validationResponse = await onValidate[fieldName]( - value, - validationResponse - ); + const customValidator = fetchByPath(onValidate, fieldName); + if (customValidator) { + validationResponse = await customValidator(value, validationResponse); } setErrors((errors) => ({ ...errors, [fieldName]: validationResponse })); return validationResponse; @@ -480,6 +687,12 @@ export default function MyPostForm(props) { { event.preventDefault(); + const modelFields = { + caption, + username, + post_url, + profile_url, + }; const validationResponses = await Promise.all( Object.keys(validations).map((fieldName) => runValidationTasks(fieldName, modelFields[fieldName]) @@ -555,7 +768,7 @@ export default function MyPostForm(props) { onChange={async (e) => { const { value } = e.target; await runValidationTasks(\\"caption\\", value); - setModelFields({ ...modelFields, caption: value }); + setCaption(value); }} errorMessage={errors.caption?.errorMessage} hasError={errors.caption?.hasError} @@ -575,7 +788,7 @@ export default function MyPostForm(props) { onChange={async (e) => { const { value } = e.target; await runValidationTasks(\\"username\\", value); - setModelFields({ ...modelFields, username: value }); + setUsername(value); }} errorMessage={errors.username?.errorMessage} hasError={errors.username?.hasError} @@ -595,7 +808,7 @@ export default function MyPostForm(props) { onChange={async (e) => { const { value } = e.target; await runValidationTasks(\\"post_url\\", value); - setModelFields({ ...modelFields, post_url: value }); + setPost_url(value); }} errorMessage={errors.post_url?.errorMessage} hasError={errors.post_url?.hasError} @@ -615,7 +828,7 @@ export default function MyPostForm(props) { onChange={async (e) => { const { value } = e.target; await runValidationTasks(\\"profile_url\\", value); - setModelFields({ ...modelFields, profile_url: value }); + setProfile_url(value); }} errorMessage={errors.profile_url?.errorMessage} hasError={errors.profile_url?.hasError} @@ -637,11 +850,12 @@ export declare type ValidationResponse = { hasError: boolean; errorMessage?: string; }; +export declare type ValidationFunction = (value: T, validationResponse: ValidationResponse) => ValidationResponse | Promise; export declare type MyPostFormInputValues = { - caption: string; - username: string; - post_url: string; - profile_url: string; + caption?: ValidationFunction; + username?: ValidationFunction; + post_url?: ValidationFunction; + profile_url?: ValidationFunction; }; export declare type MyPostFormOverridesProps = { MyPostFormGrid?: GridProps; @@ -663,9 +877,7 @@ export declare type MyPostFormProps = React.PropsWithChildren<{ errorMessage?: string; }) => void; onCancel?: () => void; - onValidate?: { - [field in keyof MyPostFormInputValues]?: (value: MyPostFormInputValues[field], validationResponse: ValidationResponse) => ValidationResponse | Promise; - }; + onValidate?: MyPostFormInputValues; }>; export default function MyPostForm(props: MyPostFormProps): React.ReactElement; " @@ -674,12 +886,9 @@ export default function MyPostForm(props: MyPostFormProps): React.ReactElement; exports[`amplify form renderer tests datastore form tests should generate a update form 1`] = ` "/* eslint-disable */ import * as React from \\"react\\"; -import { - getOverrideProps, - useStateMutationAction, -} from \\"@aws-amplify/ui-react/internal\\"; -import { Post } from \\"../models\\"; import { validateField } from \\"./utils\\"; +import { Post } from \\"../models\\"; +import { getOverrideProps } from \\"@aws-amplify/ui-react/internal\\"; import { Button, Flex, @@ -699,9 +908,8 @@ export default function MyPostForm(props) { overrides, ...rest } = props; - const [modelFields, setModelFields] = useStateMutationAction({}); - const [errors, setErrors] = useStateMutationAction({}); - const [postRecord, setPostRecord] = useStateMutationAction(post); + const [errors, setErrors] = React.useState({}); + const [postRecord, setPostRecord] = React.useState(post); React.useEffect(() => { const queryData = async () => { const record = id ? await DataStore.query(Post, id) : post; @@ -718,11 +926,9 @@ export default function MyPostForm(props) { }; const runValidationTasks = async (fieldName, value) => { let validationResponse = validateField(value, validations[fieldName]); - if (onValidate?.[fieldName]) { - validationResponse = await onValidate[fieldName]( - value, - validationResponse - ); + const customValidator = fetchByPath(onValidate, fieldName); + if (customValidator) { + validationResponse = await customValidator(value, validationResponse); } setErrors((errors) => ({ ...errors, [fieldName]: validationResponse })); return validationResponse; @@ -731,6 +937,13 @@ export default function MyPostForm(props) { { event.preventDefault(); + const modelFields = { + TextAreaFieldbbd63464, + caption, + username, + profile_url, + post_url, + }; const validationResponses = await Promise.all( Object.keys(validations).map((fieldName) => runValidationTasks(fieldName, modelFields[fieldName]) @@ -808,7 +1021,7 @@ export default function MyPostForm(props) { onChange={async (e) => { const { value } = e.target; await runValidationTasks(\\"TextAreaFieldbbd63464\\", value); - setModelFields({ ...modelFields, TextAreaFieldbbd63464: value }); + setTextAreaFieldbbd63464(value); }} errorMessage={errors.TextAreaFieldbbd63464?.errorMessage} hasError={errors.TextAreaFieldbbd63464?.hasError} @@ -828,7 +1041,7 @@ export default function MyPostForm(props) { onChange={async (e) => { const { value } = e.target; await runValidationTasks(\\"caption\\", value); - setModelFields({ ...modelFields, caption: value }); + setCaption(value); }} errorMessage={errors.caption?.errorMessage} hasError={errors.caption?.hasError} @@ -848,7 +1061,7 @@ export default function MyPostForm(props) { onChange={async (e) => { const { value } = e.target; await runValidationTasks(\\"username\\", value); - setModelFields({ ...modelFields, username: value }); + setUsername(value); }} errorMessage={errors.username?.errorMessage} hasError={errors.username?.hasError} @@ -868,7 +1081,7 @@ export default function MyPostForm(props) { onChange={async (e) => { const { value } = e.target; await runValidationTasks(\\"profile_url\\", value); - setModelFields({ ...modelFields, profile_url: value }); + setProfile_url(value); }} errorMessage={errors.profile_url?.errorMessage} hasError={errors.profile_url?.hasError} @@ -888,7 +1101,7 @@ export default function MyPostForm(props) { onChange={async (e) => { const { value } = e.target; await runValidationTasks(\\"post_url\\", value); - setModelFields({ ...modelFields, post_url: value }); + setPost_url(value); }} errorMessage={errors.post_url?.errorMessage} hasError={errors.post_url?.hasError} @@ -932,19 +1145,20 @@ export default function MyPostForm(props) { exports[`amplify form renderer tests datastore form tests should generate a update form 2`] = ` "import * as React from \\"react\\"; -import { EscapeHatchProps } from \\"@aws-amplify/ui-react/internal\\"; import { Post } from \\"../models\\"; +import { EscapeHatchProps } from \\"@aws-amplify/ui-react/internal\\"; import { GridProps, TextAreaFieldProps, TextFieldProps } from \\"@aws-amplify/ui-react\\"; export declare type ValidationResponse = { hasError: boolean; errorMessage?: string; }; +export declare type ValidationFunction = (value: T, validationResponse: ValidationResponse) => ValidationResponse | Promise; export declare type MyPostFormInputValues = { - TextAreaFieldbbd63464: string; - caption: string; - username: string; - profile_url: string; - post_url: string; + TextAreaFieldbbd63464?: ValidationFunction; + caption?: ValidationFunction; + username?: ValidationFunction; + profile_url?: ValidationFunction; + post_url?: ValidationFunction; }; export declare type MyPostFormOverridesProps = { MyPostFormGrid?: GridProps; @@ -970,9 +1184,7 @@ export declare type MyPostFormProps = React.PropsWithChildren<{ errorMessage?: string; }) => void; onCancel?: () => void; - onValidate?: { - [field in keyof MyPostFormInputValues]?: (value: MyPostFormInputValues[field], validationResponse: ValidationResponse) => ValidationResponse | Promise; - }; + onValidate?: MyPostFormInputValues; }>; export default function MyPostForm(props: MyPostFormProps): React.ReactElement; " @@ -981,12 +1193,9 @@ export default function MyPostForm(props: MyPostFormProps): React.ReactElement; exports[`amplify form renderer tests datastore form tests should render a form with multiple date types 1`] = ` "/* eslint-disable */ import * as React from \\"react\\"; -import { - getOverrideProps, - useStateMutationAction, -} from \\"@aws-amplify/ui-react/internal\\"; -import { InputGallery } from \\"../models\\"; import { validateField } from \\"./utils\\"; +import { InputGallery } from \\"../models\\"; +import { getOverrideProps } from \\"@aws-amplify/ui-react/internal\\"; import { Button, CheckboxField, @@ -1007,8 +1216,7 @@ export default function InputGalleryCreateForm(props) { overrides, ...rest } = props; - const [modelFields, setModelFields] = useStateMutationAction({}); - const [errors, setErrors] = useStateMutationAction({}); + const [errors, setErrors] = React.useState({}); const validations = { num: [], rootbeer: [], @@ -1021,11 +1229,9 @@ export default function InputGalleryCreateForm(props) { }; const runValidationTasks = async (fieldName, value) => { let validationResponse = validateField(value, validations[fieldName]); - if (onValidate?.[fieldName]) { - validationResponse = await onValidate[fieldName]( - value, - validationResponse - ); + const customValidator = fetchByPath(onValidate, fieldName); + if (customValidator) { + validationResponse = await customValidator(value, validationResponse); } setErrors((errors) => ({ ...errors, [fieldName]: validationResponse })); return validationResponse; @@ -1034,6 +1240,16 @@ export default function InputGalleryCreateForm(props) { { event.preventDefault(); + const modelFields = { + num, + rootbeer, + attend, + maybeSlide, + maybeCheck, + timestamp, + ippy, + timeisnow, + }; const validationResponses = await Promise.all( Object.keys(validations).map((fieldName) => runValidationTasks(fieldName, modelFields[fieldName]) @@ -1082,7 +1298,7 @@ export default function InputGalleryCreateForm(props) { onChange={async (e) => { const value = parseInt(e.target.value); await runValidationTasks(\\"num\\", value); - setModelFields({ ...modelFields, num: value }); + setNum(value); }} errorMessage={errors.num?.errorMessage} hasError={errors.num?.hasError} @@ -1103,7 +1319,7 @@ export default function InputGalleryCreateForm(props) { onChange={async (e) => { const value = Number(e.target.value); await runValidationTasks(\\"rootbeer\\", value); - setModelFields({ ...modelFields, rootbeer: value }); + setRootbeer(value); }} errorMessage={errors.rootbeer?.errorMessage} hasError={errors.rootbeer?.hasError} @@ -1124,7 +1340,7 @@ export default function InputGalleryCreateForm(props) { onChange={async (e) => { const value = e.target.value === \\"true\\"; await runValidationTasks(\\"attend\\", value); - setModelFields({ ...modelFields, attend: value }); + setAttend(value); }} errorMessage={errors.attend?.errorMessage} hasError={errors.attend?.hasError} @@ -1155,7 +1371,7 @@ export default function InputGalleryCreateForm(props) { onChange={async (e) => { const value = e.target.checked; await runValidationTasks(\\"maybeSlide\\", value); - setModelFields({ ...modelFields, maybeSlide: value }); + setMaybeSlide(value); }} errorMessage={errors.maybeSlide?.errorMessage} hasError={errors.maybeSlide?.hasError} @@ -1177,7 +1393,7 @@ export default function InputGalleryCreateForm(props) { onChange={async (e) => { const value = e.target.checked; await runValidationTasks(\\"maybeCheck\\", value); - setModelFields({ ...modelFields, maybeCheck: value }); + setMaybeCheck(value); }} errorMessage={errors.maybeCheck?.errorMessage} hasError={errors.maybeCheck?.hasError} @@ -1198,7 +1414,7 @@ export default function InputGalleryCreateForm(props) { onChange={async (e) => { const value = Number(new Date(e.target.value)); await runValidationTasks(\\"timestamp\\", value); - setModelFields({ ...modelFields, timestamp: value }); + setTimestamp(value); }} errorMessage={errors.timestamp?.errorMessage} hasError={errors.timestamp?.hasError} @@ -1218,7 +1434,7 @@ export default function InputGalleryCreateForm(props) { onChange={async (e) => { const { value } = e.target; await runValidationTasks(\\"ippy\\", value); - setModelFields({ ...modelFields, ippy: value }); + setIppy(value); }} errorMessage={errors.ippy?.errorMessage} hasError={errors.ippy?.hasError} @@ -1239,7 +1455,7 @@ export default function InputGalleryCreateForm(props) { onChange={async (e) => { const { value } = e.target; await runValidationTasks(\\"timeisnow\\", value); - setModelFields({ ...modelFields, timeisnow: value }); + setTimeisnow(value); }} errorMessage={errors.timeisnow?.errorMessage} hasError={errors.timeisnow?.hasError} @@ -1289,15 +1505,16 @@ export declare type ValidationResponse = { hasError: boolean; errorMessage?: string; }; +export declare type ValidationFunction = (value: T, validationResponse: ValidationResponse) => ValidationResponse | Promise; export declare type InputGalleryCreateFormInputValues = { - num: number; - rootbeer: number; - attend: boolean; - maybeSlide: boolean; - maybeCheck: boolean; - timestamp: number; - ippy: string; - timeisnow: string; + num?: ValidationFunction; + rootbeer?: ValidationFunction; + attend?: ValidationFunction; + maybeSlide?: ValidationFunction; + maybeCheck?: ValidationFunction; + timestamp?: ValidationFunction; + ippy?: ValidationFunction; + timeisnow?: ValidationFunction; }; export declare type InputGalleryCreateFormOverridesProps = { InputGalleryCreateFormGrid?: GridProps; @@ -1327,9 +1544,7 @@ export declare type InputGalleryCreateFormProps = React.PropsWithChildren<{ errorMessage?: string; }) => void; onCancel?: () => void; - onValidate?: { - [field in keyof InputGalleryCreateFormInputValues]?: (value: InputGalleryCreateFormInputValues[field], validationResponse: ValidationResponse) => ValidationResponse | Promise; - }; + onValidate?: InputGalleryCreateFormInputValues; }>; export default function InputGalleryCreateForm(props: InputGalleryCreateFormProps): React.ReactElement; " diff --git a/packages/codegen-ui-react/lib/__tests__/__utils__/amplify-renderer-generator.ts b/packages/codegen-ui-react/lib/__tests__/__utils__/amplify-renderer-generator.ts index fb68856d9..57475bdca 100644 --- a/packages/codegen-ui-react/lib/__tests__/__utils__/amplify-renderer-generator.ts +++ b/packages/codegen-ui-react/lib/__tests__/__utils__/amplify-renderer-generator.ts @@ -22,15 +22,15 @@ import { StudioForm, StudioView, } from '@aws-amplify/codegen-ui'; -import { createPrinter, createSourceFile, EmitHint } from 'typescript'; +import { createPrinter, createSourceFile, EmitHint, NewLineKind, Node } from 'typescript'; import { AmplifyFormRenderer } from '../../amplify-ui-renderers/amplify-form-renderer'; import { AmplifyRenderer } from '../../amplify-ui-renderers/amplify-renderer'; import { AmplifyViewRenderer } from '../../amplify-ui-renderers/amplify-view-renderer'; import { ModuleKind, ReactRenderConfig, ScriptKind, ScriptTarget } from '../../react-render-config'; import { loadSchemaFromJSONFile } from './example-schema'; -import { transpile } from '../../react-studio-template-renderer-helper'; +import { defaultRenderConfig, transpile } from '../../react-studio-template-renderer-helper'; -export const defaultCLIRenderConfig: ReactRenderConfig = { +export const defaultCLIRenderConfig = { module: ModuleKind.ES2020, target: ScriptTarget.ES2020, script: ScriptKind.JSX, @@ -104,3 +104,17 @@ export const renderTableJsxElement = ( return transpile(tableNode, {}).componentText; }; + +export const genericPrinter = (node: Node): string => { + const file = createSourceFile( + 'sampleFileName.js', + '', + defaultCLIRenderConfig.target, + false, + defaultRenderConfig.script, + ); + const printer = createPrinter({ + newLine: NewLineKind.LineFeed, + }); + return printer.printNode(EmitHint.Unspecified, node, file); +}; diff --git a/packages/codegen-ui-react/lib/__tests__/forms/__snapshots__/form-state.test.ts.snap b/packages/codegen-ui-react/lib/__tests__/forms/__snapshots__/form-state.test.ts.snap new file mode 100644 index 000000000..112ae35b1 --- /dev/null +++ b/packages/codegen-ui-react/lib/__tests__/forms/__snapshots__/form-state.test.ts.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`nested state should generate state structure for nested keyPath 1`] = `"{ ...bio, favoriteAnimal: { ...bio?.favoriteAnimal, animalMeta: { ...bio?.favoriteAnimal?.animalMeta, family: { ...bio?.favoriteAnimal?.animalMeta?.family, genus: value } } } }"`; + +exports[`nested state should generate value for 2nd level deep object 1`] = `"{ ...bio, firstName: \\"John C\\" }"`; + +exports[`set field state should generate state call for nested object 1`] = `"setBio({ ...bio, favoriteAnimal: { ...bio?.favoriteAnimal, animalMeta: { ...bio?.favoriteAnimal?.animalMeta, family: { ...bio?.favoriteAnimal?.animalMeta?.family, genus: \\"hello World\\" } } } })"`; + +exports[`set field state should generate state call for non-nested objects 1`] = `"setFirstName(\\"john c\\")"`; diff --git a/packages/codegen-ui-react/lib/__tests__/forms/__snapshots__/type-helper.test.ts.snap b/packages/codegen-ui-react/lib/__tests__/forms/__snapshots__/type-helper.test.ts.snap new file mode 100644 index 000000000..58b733a96 --- /dev/null +++ b/packages/codegen-ui-react/lib/__tests__/forms/__snapshots__/type-helper.test.ts.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should generate nested object should generate type for nested object 1`] = ` +"export declare type myCreateFormInputValues = { + firstName?: ValidationFunction; + isExplorer?: ValidationFunction; + bio?: { + favoriteAnimal?: { + animalMeta?: { + family?: { + genus?: ValidationFunction; + }; + earliestRecord?: ValidationFunction; + }; + }; + }; +};" +`; + +exports[`should generate nested object should generate type for non nested object 1`] = ` +"export declare type myCreateFormInputValues = { + firstName?: ValidationFunction; + isExplorer?: ValidationFunction; +};" +`; diff --git a/packages/codegen-ui-react/lib/__tests__/forms/form-state.test.ts b/packages/codegen-ui-react/lib/__tests__/forms/form-state.test.ts new file mode 100644 index 000000000..7f8896234 --- /dev/null +++ b/packages/codegen-ui-react/lib/__tests__/forms/form-state.test.ts @@ -0,0 +1,59 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +import { factory } from 'typescript'; +import { buildNestedStateSet, setFieldState } from '../../forms/form-state'; +import { genericPrinter } from '../__utils__'; + +describe('nested state', () => { + it('should generate state structure for nested keyPath', () => { + const state = buildNestedStateSet( + ['bio', 'favoriteAnimal', 'animalMeta', 'family', 'genus'], + ['bio'], + factory.createIdentifier('value'), + ); + const response = genericPrinter(state); + expect(response).toMatchSnapshot(); + }); + + it('should generate value for 2nd level deep object', () => { + const state = buildNestedStateSet(['bio', 'firstName'], ['bio'], factory.createStringLiteral('John C')); + const response = genericPrinter(state); + expect(response).toMatchSnapshot(); + }); + + it('should throw error for 1 level deep path', () => { + expect(() => buildNestedStateSet(['firstName'], ['firstName'], factory.createStringLiteral('John C'))).toThrowError( + 'keyPath needs a length larger than 1 to build nested state object', + ); + }); +}); + +describe('set field state', () => { + it('should generate state call for nested object', () => { + const fieldStateSetter = setFieldState( + 'bio.favoriteAnimal.animalMeta.family.genus', + factory.createStringLiteral('hello World'), + ); + const response = genericPrinter(fieldStateSetter); + expect(response).toMatchSnapshot(); + }); + + it('should generate state call for non-nested objects', () => { + const fieldStateSetter = setFieldState('firstName', factory.createStringLiteral('john c')); + const response = genericPrinter(fieldStateSetter); + expect(response).toMatchSnapshot(); + }); +}); diff --git a/packages/codegen-ui-react/lib/__tests__/forms/type-helper.test.ts b/packages/codegen-ui-react/lib/__tests__/forms/type-helper.test.ts new file mode 100644 index 000000000..f2cd3d60c --- /dev/null +++ b/packages/codegen-ui-react/lib/__tests__/forms/type-helper.test.ts @@ -0,0 +1,61 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import { FieldConfigMetadata } from '@aws-amplify/codegen-ui'; +import { generateOnValidationType } from '../../forms/type-helper'; +import { genericPrinter } from '../__utils__'; + +describe('should generate nested object', () => { + it('should generate type for nested object', () => { + const fieldConfigs: Record = { + 'bio.favoriteAnimal.animalMeta.family.genus': { + dataType: 'String', + validationRules: [], + }, + 'bio.favoriteAnimal.animalMeta.earliestRecord': { + dataType: 'AWSTimestamp', + validationRules: [], + }, + firstName: { + dataType: 'String', + validationRules: [], + }, + isExplorer: { + dataType: 'Boolean', + validationRules: [], + }, + }; + const types = generateOnValidationType('myCreateForm', fieldConfigs); + const response = genericPrinter(types); + expect(response).toMatchSnapshot(); + }); + + it('should generate type for non nested object', () => { + const fieldConfigs: Record = { + firstName: { + dataType: 'String', + validationRules: [], + }, + isExplorer: { + dataType: 'Boolean', + validationRules: [], + }, + }; + const types = generateOnValidationType('myCreateForm', fieldConfigs); + const response = genericPrinter(types); + expect(response).toMatchSnapshot(); + }); +}); diff --git a/packages/codegen-ui-react/lib/__tests__/react-forms/__snapshots__/form-renderer-helper.test.ts.snap b/packages/codegen-ui-react/lib/__tests__/react-forms/__snapshots__/form-renderer-helper.test.ts.snap index 041c5fbe1..ec13fc0a8 100644 --- a/packages/codegen-ui-react/lib/__tests__/react-forms/__snapshots__/form-renderer-helper.test.ts.snap +++ b/packages/codegen-ui-react/lib/__tests__/react-forms/__snapshots__/form-renderer-helper.test.ts.snap @@ -8,9 +8,7 @@ exports[`form-render utils should generate before & complete types if datastore errorMessage?: string; }) => void; onCancel?: () => void; - onValidate?: { - [field in keyof mySampleFormInputValues]?: (value: mySampleFormInputValues[field], validationResponse: ValidationResponse) => ValidationResponse | Promise; - }; + onValidate?: mySampleFormInputValues; }" `; @@ -18,8 +16,6 @@ exports[`form-render utils should generate regular onsubmit if dataSourceType is "{ onSubmit: (fields: Record) => void; onCancel?: () => void; - onValidate?: { - [field in keyof myCustomFormInputValues]?: (value: myCustomFormInputValues[field], validationResponse: ValidationResponse) => ValidationResponse | Promise; - }; + onValidate?: myCustomFormInputValues; }" `; diff --git a/packages/codegen-ui-react/lib/__tests__/studio-ui-codegen-react-forms.test.ts b/packages/codegen-ui-react/lib/__tests__/studio-ui-codegen-react-forms.test.ts index b6ee4acc5..2ef8bf367 100644 --- a/packages/codegen-ui-react/lib/__tests__/studio-ui-codegen-react-forms.test.ts +++ b/packages/codegen-ui-react/lib/__tests__/studio-ui-codegen-react-forms.test.ts @@ -64,5 +64,11 @@ describe('amplify form renderer tests', () => { expect(componentText).toMatchSnapshot(); expect(declaration).toMatchSnapshot(); }); + + it('should render nested json fields', () => { + const { componentText, declaration } = generateWithAmplifyFormRenderer('forms/bio-nested-create', undefined); + expect(componentText).toMatchSnapshot(); + expect(declaration).toMatchSnapshot(); + }); }); }); diff --git a/packages/codegen-ui-react/lib/__tests__/utils/fetch-by-path.test.ts b/packages/codegen-ui-react/lib/__tests__/utils/fetch-by-path.test.ts new file mode 100644 index 000000000..29a4de2cb --- /dev/null +++ b/packages/codegen-ui-react/lib/__tests__/utils/fetch-by-path.test.ts @@ -0,0 +1,39 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import { fetchByPath } from '../../utils/json-path-fetch'; + +describe('fetch by path util', () => { + const nestedObj = { + levelOne: { + levelTwo: { + levelThree: { + bingo: (value: string) => `Winner Winner ${value}!`, + }, + }, + }, + }; + it('should fetch value from nested object', () => { + const fn: Function = fetchByPath(nestedObj, 'levelOne.levelTwo.levelThree.bingo'); + const result = fn('helloWorld'); + expect(result).toEqual('Winner Winner helloWorld!'); + }); + + it('should return undefined if value does not exist in nested object', () => { + const result = fetchByPath(nestedObj, 'levelOne.levelTwo.nonExistentLevel'); + expect(result).toBeUndefined(); + }); +}); diff --git a/packages/codegen-ui-react/lib/amplify-ui-renderers/form.ts b/packages/codegen-ui-react/lib/amplify-ui-renderers/form.ts index 5c5d26c7c..a6fa36eb0 100644 --- a/packages/codegen-ui-react/lib/amplify-ui-renderers/form.ts +++ b/packages/codegen-ui-react/lib/amplify-ui-renderers/form.ts @@ -27,7 +27,7 @@ import { buildOpeningElementProperties } from '../react-component-render-helper' import { ImportCollection } from '../imports'; import { getActionIdentifier } from '../workflow'; import { buildDataStoreExpression } from '../forms'; -import { onSubmitValidationRun } from '../forms/form-renderer-helper'; +import { onSubmitValidationRun, buildModelFieldObject } from '../forms/form-renderer-helper'; export default class FormRenderer extends ReactComponentRenderer { constructor( @@ -190,6 +190,7 @@ export default class FormRenderer extends ReactComponentRenderer = { - create: 'Amplify.DataStoreCreateItemAction', - update: 'Amplify.DataStoreUpdateItemAction', -}; - -export const FieldStateVariable = (componentName: string): StateStudioComponentProperty => ({ - componentName, - property: 'fields', -}); - -function capitalizeFirstLetter(val: string) { - return val.charAt(0).toUpperCase() + val.slice(1); -} - -/** - * - formFields - */ -export const buildFieldStateStatements = (formName: string, importCollection: ImportCollection) => { - importCollection.addMappedImport(ImportValue.USE_STATE_MUTATION_ACTION); - - return factory.createVariableStatement( - undefined, - factory.createVariableDeclarationList( - [ - factory.createVariableDeclaration( - factory.createArrayBindingPattern([ - factory.createBindingElement( - undefined, - undefined, - factory.createIdentifier(getStateName(FieldStateVariable(formName))), - undefined, - ), - factory.createBindingElement( - undefined, - undefined, - factory.createIdentifier(getSetStateName(FieldStateVariable(formName))), - undefined, - ), - ]), - undefined, - undefined, - factory.createCallExpression(factory.createIdentifier('useStateMutationAction'), undefined, [ - factory.createObjectLiteralExpression(), - ]), - ), - ], - NodeFlags.Const, - ), - ); -}; +import { setFieldState } from './form-state'; +import { buildOnValidateType } from './type-helper'; export const buildMutationBindings = (form: StudioForm) => { const { - dataType: { dataSourceType }, + dataType: { dataSourceType, dataTypeName }, formActionType, } = form; const elements: BindingElement[] = []; @@ -107,7 +55,7 @@ export const buildMutationBindings = (form: StudioForm) => { factory.createBindingElement( undefined, undefined, - factory.createIdentifier(lowerCaseFirst(form.dataType.dataTypeName)), + factory.createIdentifier(lowerCaseFirst(dataTypeName)), undefined, ), ); @@ -130,71 +78,6 @@ export const buildMutationBindings = (form: StudioForm) => { return elements; }; -function getInputValuesTypeName(formName: string): string { - return `${formName}InputValues`; -} - -/** - onValidate?: { - [field in keyof CustomFormCreateDogInputValues]?: ( - value: CustomFormCreateDogInputValues[field], - validationResponse: ValidationResponse - ) => ValidationResponse | Promise; - }; - */ -function buildOnValidateType(formName: string) { - return factory.createPropertySignature( - undefined, - factory.createIdentifier('onValidate'), - factory.createToken(SyntaxKind.QuestionToken), - factory.createMappedTypeNode( - undefined, - factory.createTypeParameterDeclaration( - factory.createIdentifier('field'), - factory.createTypeOperatorNode( - SyntaxKind.KeyOfKeyword, - factory.createTypeReferenceNode(factory.createIdentifier(getInputValuesTypeName(formName)), undefined), - ), - undefined, - ), - undefined, - factory.createToken(SyntaxKind.QuestionToken), - factory.createFunctionTypeNode( - undefined, - [ - factory.createParameterDeclaration( - undefined, - undefined, - undefined, - factory.createIdentifier('value'), - undefined, - factory.createIndexedAccessTypeNode( - factory.createTypeReferenceNode(factory.createIdentifier(getInputValuesTypeName(formName)), undefined), - factory.createTypeReferenceNode(factory.createIdentifier('field'), undefined), - ), - undefined, - ), - factory.createParameterDeclaration( - undefined, - undefined, - undefined, - factory.createIdentifier('validationResponse'), - undefined, - factory.createTypeReferenceNode(factory.createIdentifier('ValidationResponse'), undefined), - undefined, - ), - ], - factory.createUnionTypeNode([ - factory.createTypeReferenceNode(factory.createIdentifier('ValidationResponse'), undefined), - factory.createTypeReferenceNode(factory.createIdentifier('Promise'), [ - factory.createTypeReferenceNode(factory.createIdentifier('ValidationResponse'), undefined), - ]), - ]), - ), - ), - ); -} - /* generate params in typed props - datastore (onSubmitBefore(fields) & onSubmitComplete({saveSuccessful, errorMessage})) @@ -334,31 +217,6 @@ export const buildFormPropNode = (form: StudioForm) => { return factory.createTypeLiteralNode(propSignatures); }; -export const buildStateMutationStatement = (name: string, defaultValue: Expression) => { - return factory.createVariableStatement( - undefined, - factory.createVariableDeclarationList( - [ - factory.createVariableDeclaration( - factory.createArrayBindingPattern([ - factory.createBindingElement(undefined, undefined, factory.createIdentifier(name), undefined), - factory.createBindingElement( - undefined, - undefined, - factory.createIdentifier(`set${capitalizeFirstLetter(name)}`), - undefined, - ), - ]), - undefined, - undefined, - factory.createCallExpression(factory.createIdentifier('useStateMutationAction'), undefined, [defaultValue]), - ), - ], - NodeFlags.Const, - ), - ); -}; - export const createValidationExpression = (validationRules: FieldValidationConfiguration[] = []): Expression => { const validateExpressions = validationRules.map((rule) => { const elements: ObjectLiteralElementLike[] = [ @@ -401,15 +259,10 @@ export const createValidationExpression = (validationRules: FieldValidationConfi }; export const addFormAttributes = ( - component: StudioComponent | StudioComponentChild, - componentMetadata: ComponentMetadata, + { name: componentName, componentType }: StudioComponent | StudioComponentChild, + formMetadata: FormMetadata, ) => { - const attributes = []; - const { formMetadata } = componentMetadata; - - // do some sort of mapping of the componetName from the dataschema fields - // then map this with the componentType - + const attributes: JsxAttribute[] = []; /* boolean => RadioGroupField const value = e.target.value.toLowerCase() === 'yes'; @@ -421,19 +274,32 @@ export const addFormAttributes = ( const value = Boolean(e.target.checked) */ - if (formMetadata && component.name in formMetadata?.fieldConfigs) { - const { dataType } = formMetadata.fieldConfigs[component.name]; - attributes.push(buildOnChangeStatement(component.name, component.componentType, dataType)); + if (componentName in formMetadata.fieldConfigs) { + const fieldConfig = formMetadata.fieldConfigs[componentName]; + /* + if the componetName is a dotPath we need to change the access expression to the following + - bio.user.favorites.Quote => errors['bio.user.favorites.Quote']?.errorMessage + if it's a regular componetName it will use the following expression + - bio => errors.bio?.errorMessage + */ + const errorKey = + componentName.split('.').length > 1 + ? factory.createElementAccessExpression( + factory.createIdentifier('errors'), + factory.createStringLiteral(componentName), + ) + : factory.createPropertyAccessExpression( + factory.createIdentifier('errors'), + factory.createIdentifier(componentName), + ); + attributes.push(buildOnChangeStatement(componentName, componentType, fieldConfig)); attributes.push( factory.createJsxAttribute( factory.createIdentifier('errorMessage'), factory.createJsxExpression( undefined, factory.createPropertyAccessChain( - factory.createPropertyAccessExpression( - factory.createIdentifier('errors'), - factory.createIdentifier(component.name), - ), + errorKey, factory.createToken(SyntaxKind.QuestionDotToken), factory.createIdentifier('errorMessage'), ), @@ -444,10 +310,7 @@ export const addFormAttributes = ( factory.createJsxExpression( undefined, factory.createPropertyAccessChain( - factory.createPropertyAccessExpression( - factory.createIdentifier('errors'), - factory.createIdentifier(component.name), - ), + errorKey, factory.createToken(SyntaxKind.QuestionDotToken), factory.createIdentifier('hasError'), ), @@ -456,7 +319,7 @@ export const addFormAttributes = ( ); } - if (component.name === 'SubmitButton') { + if (componentName === 'SubmitButton') { attributes.push( factory.createJsxAttribute( factory.createIdentifier('isDisabled'), @@ -503,7 +366,7 @@ export const addFormAttributes = ( ), ); } - if (component.name === 'CancelButton') { + if (componentName === 'CancelButton') { attributes.push( factory.createJsxAttribute( factory.createIdentifier('onClick'), @@ -535,7 +398,7 @@ export const addFormAttributes = ( return attributes; }; -export const buildOnChangeStatement = (fieldName: string, fieldType: string, dataType?: DataFieldDataType) => { +export const buildOnChangeStatement = (fieldName: string, fieldType: string, fieldConfig: FieldConfigMetadata) => { return factory.createJsxAttribute( factory.createIdentifier('onChange'), factory.createJsxExpression( @@ -558,7 +421,7 @@ export const buildOnChangeStatement = (fieldName: string, fieldType: string, dat factory.createToken(SyntaxKind.EqualsGreaterThanToken), factory.createBlock( [ - buildTargetVariable(fieldType, dataType), + buildTargetVariable(fieldType, fieldConfig.dataType), factory.createExpressionStatement( factory.createAwaitExpression( factory.createCallExpression(factory.createIdentifier('runValidationTasks'), undefined, [ @@ -567,20 +430,7 @@ export const buildOnChangeStatement = (fieldName: string, fieldType: string, dat ]), ), ), - factory.createExpressionStatement( - factory.createCallExpression(factory.createIdentifier('setModelFields'), undefined, [ - factory.createObjectLiteralExpression( - [ - factory.createSpreadAssignment(factory.createIdentifier('modelFields')), - factory.createPropertyAssignment( - factory.createIdentifier(fieldName), - factory.createIdentifier('value'), - ), - ], - false, - ), - ]), - ), + factory.createExpressionStatement(setFieldState(fieldName, factory.createIdentifier('value'))), ], true, ), @@ -695,11 +545,13 @@ export const buildOverrideTypesBindings = ( ), ); row.forEach((field) => { + const propKey = + field.split('.').length > 1 ? factory.createStringLiteral(field) : factory.createIdentifier(field); const componentTypePropName = `${formDefinition.elements[field].componentType}Props`; typeNodes.push( factory.createPropertySignature( undefined, - factory.createIdentifier(field), + propKey, factory.createToken(SyntaxKind.QuestionToken), factory.createTypeReferenceNode(factory.createIdentifier(componentTypePropName), undefined), ), @@ -720,74 +572,22 @@ export const buildOverrideTypesBindings = ( ); }; -// declare type ValidationResponse = {hasError: boolean, errorMessage?: string} -export const validationResponseTypeAliasDeclaration = factory.createTypeAliasDeclaration( - undefined, - [factory.createModifier(SyntaxKind.ExportKeyword), factory.createModifier(SyntaxKind.DeclareKeyword)], - factory.createIdentifier('ValidationResponse'), - undefined, - factory.createTypeLiteralNode([ - factory.createPropertySignature( - undefined, - factory.createIdentifier('hasError'), - undefined, - factory.createKeywordTypeNode(SyntaxKind.BooleanKeyword), - ), - factory.createPropertySignature( - undefined, - factory.createIdentifier('errorMessage'), - factory.createToken(SyntaxKind.QuestionToken), - factory.createKeywordTypeNode(SyntaxKind.StringKeyword), - ), - ]), -); - /** - example: - declare type MyFormInputValues = { - name: string; - age: number; - email: string; - } + * builds validation variable + * for nested values it will mention the full path as that corresponds to the fields + * this will also link to error messages + * + * const validations = { post_url: [{ type: "URL" }], 'user.status': [] }; + * + * @param fieldConfigs + * @returns */ - -export function buildInputValuesTypeAliasDeclaration( - formName: string, - fieldConfigs?: Record, -) { - const properties = fieldConfigs - ? Object.entries(fieldConfigs).map(([fieldName, fieldConfig]) => { - const { dataType } = fieldConfig; - const typescriptType = - dataType && typeof dataType === 'string' && dataType in DATA_TYPE_TO_TYPESCRIPT_MAP - ? DATA_TYPE_TO_TYPESCRIPT_MAP[dataType] - : SyntaxKind.StringKeyword; - - return factory.createPropertySignature( - undefined, - factory.createIdentifier(fieldName), - undefined, - factory.createKeywordTypeNode(typescriptType), - ); - }) - : []; - - return factory.createTypeAliasDeclaration( - undefined, - [factory.createModifier(SyntaxKind.ExportKeyword), factory.createModifier(SyntaxKind.DeclareKeyword)], - factory.createIdentifier(getInputValuesTypeName(formName)), - undefined, - factory.createTypeLiteralNode(properties), - ); -} - export function buildValidations(fieldConfigs: Record) { - const validationsForField = Object.entries(fieldConfigs).map(([fieldName, fieldConfig]) => - factory.createPropertyAssignment( - factory.createIdentifier(fieldName), - createValidationExpression(fieldConfig.validationRules), - ), - ); + const validationsForField = Object.entries(fieldConfigs).map(([fieldName, { validationRules }]) => { + const propKey = + fieldName.split('.').length > 1 ? factory.createStringLiteral(fieldName) : factory.createIdentifier(fieldName); + return factory.createPropertyAssignment(propKey, createValidationExpression(validationRules)); + }); return factory.createVariableStatement( undefined, @@ -871,12 +671,25 @@ export const runValidationTasksFunction = factory.createVariableStatement( NodeFlags.Let, ), ), - factory.createIfStatement( - factory.createElementAccessChain( - factory.createIdentifier('onValidate'), - factory.createToken(SyntaxKind.QuestionDotToken), - factory.createIdentifier('fieldName'), + factory.createVariableStatement( + undefined, + factory.createVariableDeclarationList( + [ + factory.createVariableDeclaration( + factory.createIdentifier('customValidator'), + undefined, + undefined, + factory.createCallExpression(factory.createIdentifier('fetchByPath'), undefined, [ + factory.createIdentifier('onValidate'), + factory.createIdentifier('fieldName'), + ]), + ), + ], + NodeFlags.Const, ), + ), + factory.createIfStatement( + factory.createIdentifier('customValidator'), factory.createBlock( [ factory.createExpressionStatement( @@ -884,14 +697,10 @@ export const runValidationTasksFunction = factory.createVariableStatement( factory.createIdentifier('validationResponse'), factory.createToken(SyntaxKind.EqualsToken), factory.createAwaitExpression( - factory.createCallExpression( - factory.createElementAccessExpression( - factory.createIdentifier('onValidate'), - factory.createIdentifier('fieldName'), - ), - undefined, - [factory.createIdentifier('value'), factory.createIdentifier('validationResponse')], - ), + factory.createCallExpression(factory.createIdentifier('customValidator'), undefined, [ + factory.createIdentifier('value'), + factory.createIdentifier('validationResponse'), + ]), ), ), ), @@ -943,6 +752,45 @@ export const runValidationTasksFunction = factory.createVariableStatement( NodeFlags.Const, ), ); +/** + * builds modelFields object which is used to validate, onSubmit, onSuccess/onError + * + * ex. [name, content, updatedAt] + * + * const modelFields = { + * name, + * content, + * updatedAt + * }; + * @param fieldConfigs + * @returns + */ +export const buildModelFieldObject = (fieldConfigs: Record = {}) => { + const fieldSet = new Set(); + const fields = Object.keys(fieldConfigs).reduce((acc, value) => { + const fieldName = value.split('.')[0]; + if (!fieldSet.has(fieldName)) { + acc.push(factory.createShorthandPropertyAssignment(factory.createIdentifier(fieldName), undefined)); + fieldSet.add(fieldName); + } + return acc; + }, []); + + return factory.createVariableStatement( + undefined, + factory.createVariableDeclarationList( + [ + factory.createVariableDeclaration( + factory.createIdentifier('modelFields'), + undefined, + undefined, + factory.createObjectLiteralExpression(fields, true), + ), + ], + NodeFlags.Const, + ), + ); +}; /** const validationResponses = await Promise.all( diff --git a/packages/codegen-ui-react/lib/forms/form-state.ts b/packages/codegen-ui-react/lib/forms/form-state.ts new file mode 100644 index 000000000..363a0c615 --- /dev/null +++ b/packages/codegen-ui-react/lib/forms/form-state.ts @@ -0,0 +1,174 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import { FieldConfigMetadata, DataFieldDataType } from '@aws-amplify/codegen-ui'; +import { + factory, + Statement, + Expression, + NodeFlags, + Identifier, + SyntaxKind, + ObjectLiteralExpression, + CallExpression, +} from 'typescript'; + +export const capitalizeFirstLetter = (val: string) => { + return val.charAt(0).toUpperCase() + val.slice(1); +}; + +export const getSetNameIdentifier = (value: string): Identifier => { + return factory.createIdentifier(`set${capitalizeFirstLetter(value)}`); +}; + +export const getDefaultValueExpression = (name: string, dataType?: DataFieldDataType): Expression => { + // it's a nonModel or relationship object + if (dataType && typeof dataType === 'object' && !('enum' in dataType)) { + return factory.createObjectLiteralExpression(); + } + // the name itself is a nested json object + if (name.split('.').length > 1) { + return factory.createObjectLiteralExpression(); + } + return factory.createIdentifier('undefined'); +}; + +/** + * iterates field configs to create useState hooks for each field + * populates the default values as undefined if it as a nested object, relationship model or nonModel + * the default is an empty object + * @param fieldConfigs + * @returns + */ +export const getUseStateHooks = (fieldConfigs: Record): Statement[] => { + const stateNames = new Set(); + return Object.entries(fieldConfigs).reduce((acc, [name, { dataType }]) => { + const stateName = name.split('.')[0]; + if (!stateNames.has(stateName)) { + acc.push(buildUseStateExpression(stateName, getDefaultValueExpression(name, dataType))); + stateNames.add(stateName); + } + return acc; + }, []); +}; + +/** + * const [name, setName] = React.useState({default_expression}); + * + * name is the value we are looking to set + * defaultValue is is the value to set for the useState + * @param name + * @param defaultValue + * @returns + */ +export const buildUseStateExpression = (name: string, defaultValue: Expression): Statement => { + return factory.createVariableStatement( + undefined, + factory.createVariableDeclarationList( + [ + factory.createVariableDeclaration( + factory.createArrayBindingPattern([ + factory.createBindingElement(undefined, undefined, factory.createIdentifier(name), undefined), + factory.createBindingElement(undefined, undefined, getSetNameIdentifier(name), undefined), + ]), + undefined, + undefined, + factory.createCallExpression( + factory.createPropertyAccessExpression( + factory.createIdentifier('React'), + factory.createIdentifier('useState'), + ), + undefined, + [defaultValue], + ), + ), + ], + NodeFlags.Const, + ), + ); +}; + +/** + * turns ['myNestedObject', 'value', 'nestedValue', 'leaf'] + * + * into myNestedObject?.value?.nestedValue?.leaf + * + * @param values + * @returns + */ +export const buildAccessChain = (values: string[]): Expression => { + if (values.length < 0) { + throw new Error('Need at least one value in the values array'); + } + if (values.length > 1) { + const [parent, child, ...rest] = values; + let propChain = factory.createPropertyAccessChain( + factory.createIdentifier(parent), + factory.createToken(SyntaxKind.QuestionDotToken), + factory.createIdentifier(child), + ); + if (rest.length) { + rest.forEach((value) => { + propChain = factory.createPropertyAccessChain( + propChain, + factory.createToken(SyntaxKind.QuestionDotToken), + factory.createIdentifier(value), + ); + }); + } + return propChain; + } + return factory.createIdentifier(values[0]); +}; + +export const buildNestedStateSet = ( + keyPath: string[], + currentKeyPath: string[], + value: Expression, + index = 1, +): ObjectLiteralExpression => { + if (keyPath.length <= 1) { + throw new Error('keyPath needs a length larger than 1 to build nested state object'); + } + const currentKey = keyPath[index]; + // the value of the index is what decides if we have reached the leaf property of the nested object + if (keyPath.length - 1 === index) { + return factory.createObjectLiteralExpression([ + factory.createSpreadAssignment(buildAccessChain(currentKeyPath)), + factory.createPropertyAssignment(factory.createIdentifier(currentKey), value), + ]); + } + const currentSpreadAssignment = buildAccessChain(currentKeyPath); + currentKeyPath.push(currentKey); + return factory.createObjectLiteralExpression([ + factory.createSpreadAssignment(currentSpreadAssignment), + factory.createPropertyAssignment( + factory.createIdentifier(currentKey), + buildNestedStateSet(keyPath, currentKeyPath, value, index + 1), + ), + ]); +}; + +// updating state +export const setFieldState = (name: string, value: Expression): CallExpression => { + if (name.split('.').length > 1) { + const keyPath = name.split('.'); + return factory.createCallExpression(getSetNameIdentifier(keyPath[0]), undefined, [ + buildNestedStateSet(keyPath, [keyPath[0]], value), + ]); + } + return factory.createCallExpression(getSetNameIdentifier(name), undefined, [value]); +}; diff --git a/packages/codegen-ui-react/lib/forms/react-form-renderer.ts b/packages/codegen-ui-react/lib/forms/react-form-renderer.ts index 8084c255c..1d0ba17a3 100644 --- a/packages/codegen-ui-react/lib/forms/react-form-renderer.ts +++ b/packages/codegen-ui-react/lib/forms/react-form-renderer.ts @@ -62,15 +62,14 @@ import { addUseEffectWrapper } from '../utils/generate-react-hooks'; import { RequiredKeys } from '../utils/type-utils'; import { buildFormPropNode, - buildInputValuesTypeAliasDeclaration, buildMutationBindings, buildOverrideTypesBindings, - buildStateMutationStatement, buildUpdateDatastoreQuery, buildValidations, runValidationTasksFunction, - validationResponseTypeAliasDeclaration, } from './form-renderer-helper'; +import { buildUseStateExpression } from './form-state'; // getUseStateHooks +import { generateOnValidationType, validationFunctionType, validationResponseType } from './type-helper'; export abstract class ReactFormTemplateRenderer extends StudioTemplateRenderer< string, @@ -242,6 +241,12 @@ export abstract class ReactFormTemplateRenderer extends StudioTemplateRenderer< abstract renderJsx(component: StudioComponent, parent?: StudioNode): JsxElement | JsxFragment | JsxSelfClosingElement; private renderBindingPropsType(): TypeAliasDeclaration[] { + const { + name: formName, + formActionType, + dataType: { dataSourceType, dataTypeName }, + } = this.component; + const fieldConfigs = this.componentMetadata.formMetadata?.fieldConfigs ?? {}; const overrideTypeAliasDeclaration = buildOverrideTypesBindings( this.formComponent, this.formDefinition, @@ -253,22 +258,23 @@ export abstract class ReactFormTemplateRenderer extends StudioTemplateRenderer< factory.createIdentifier('overrides'), factory.createToken(SyntaxKind.QuestionToken), factory.createUnionTypeNode([ - factory.createTypeReferenceNode(`${this.formComponent.name}OverridesProps`, undefined), + factory.createTypeReferenceNode(`${formName}OverridesProps`, undefined), factory.createKeywordTypeNode(SyntaxKind.UndefinedKeyword), factory.createLiteralTypeNode(factory.createNull()), ]), ), ]); - const formPropType = getComponentPropName(this.component.name); + const formPropType = getComponentPropName(formName); this.importCollection.addMappedImport(ImportValue.ESCAPE_HATCH_PROPS); - if (this.component.formActionType === 'update') { - this.importCollection.addImport(ImportSource.LOCAL_MODELS, this.component.dataType.dataTypeName); + if (dataSourceType === 'DataStore' && formActionType === 'update') { + this.importCollection.addImport(ImportSource.LOCAL_MODELS, dataTypeName); } return [ - validationResponseTypeAliasDeclaration, - buildInputValuesTypeAliasDeclaration(this.formComponent.name, this.componentMetadata.formMetadata?.fieldConfigs), + validationResponseType, + validationFunctionType, + generateOnValidationType(formName, fieldConfigs), overrideTypeAliasDeclaration, factory.createTypeAliasDeclaration( undefined, @@ -296,9 +302,16 @@ export abstract class ReactFormTemplateRenderer extends StudioTemplateRenderer< const statements: Statement[] = []; const elements: BindingElement[] = []; const { formMetadata } = this.componentMetadata; - const { dataTypeName } = this.component.dataType; + const { + dataType: { dataTypeName, dataSourceType }, + formActionType, + } = this.component; const lowerCaseDataTypeName = lowerCaseFirst(dataTypeName); + if (!formMetadata) { + throw new Error(`Form Metadata is missing from form: ${this.component.name}`); + } + // add in hooks for before/complete with ds and basic onSubmit with props elements.push(...buildMutationBindings(this.component)); // add onValidate prop @@ -336,31 +349,26 @@ export abstract class ReactFormTemplateRenderer extends StudioTemplateRenderer< ), ); - this.importCollection.addMappedImport(ImportValue.USE_STATE_MUTATION_ACTION); + // statements.push(...getUseStateHooks(formMetadata.fieldConfigs)); - statements.push(buildStateMutationStatement('modelFields', factory.createObjectLiteralExpression())); + statements.push(buildUseStateExpression('errors', factory.createObjectLiteralExpression())); - statements.push(buildStateMutationStatement('errors', factory.createObjectLiteralExpression())); + this.importCollection.addMappedImport(ImportValue.VALIDATE_FIELD); + this.importCollection.addMappedImport(ImportValue.FETCH_BY_PATH); // add model import for datastore type - if (this.component.dataType.dataSourceType === 'DataStore') { - this.importCollection.addImport(ImportSource.LOCAL_MODELS, this.component.dataType.dataTypeName); - if (this.component.formActionType === 'update') { + if (dataSourceType === 'DataStore') { + this.importCollection.addImport(ImportSource.LOCAL_MODELS, dataTypeName); + if (formActionType === 'update') { statements.push( - buildStateMutationStatement( - `${lowerCaseDataTypeName}Record`, - factory.createIdentifier(lowerCaseDataTypeName), - ), + buildUseStateExpression(`${lowerCaseDataTypeName}Record`, factory.createIdentifier(lowerCaseDataTypeName)), ); statements.push(addUseEffectWrapper(buildUpdateDatastoreQuery(dataTypeName), ['id', lowerCaseDataTypeName])); } } - if (formMetadata) { - this.importCollection.addMappedImport(ImportValue.VALIDATE_FIELD); - statements.push(buildValidations(formMetadata.fieldConfigs)); - statements.push(runValidationTasksFunction); - } + statements.push(buildValidations(formMetadata.fieldConfigs)); + statements.push(runValidationTasksFunction); return statements; } diff --git a/packages/codegen-ui-react/lib/forms/type-helper.ts b/packages/codegen-ui-react/lib/forms/type-helper.ts new file mode 100644 index 000000000..99ec3ce88 --- /dev/null +++ b/packages/codegen-ui-react/lib/forms/type-helper.ts @@ -0,0 +1,215 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +import { FieldConfigMetadata, DataFieldDataType } from '@aws-amplify/codegen-ui'; +import { factory, SyntaxKind, KeywordTypeSyntaxKind, TypeElement, PropertySignature, TypeNode } from 'typescript'; +import { DATA_TYPE_TO_TYPESCRIPT_MAP } from './typescript-type-map'; + +type Node = { + [n: string]: T | Node; +}; +/** + * based on the provided dataType (appsync scalar) + * converst to the correct typescript type + * default assumption is string type + * + * @param dataType + * @returns + */ +const getSyntaxKindType = (dataType?: DataFieldDataType) => { + let typescriptType = SyntaxKind.StringKeyword; + if (dataType && typeof dataType === 'string' && dataType in DATA_TYPE_TO_TYPESCRIPT_MAP) { + typescriptType = DATA_TYPE_TO_TYPESCRIPT_MAP[dataType]; + } + return typescriptType; +}; + +const getInputValuesTypeName = (formName: string): string => { + return `${formName}InputValues`; +}; + +/** + * given the nested json paths rejoin them into one object + * where the leafs are the types ex. string | number | boolean + * src: https://stackoverflow.com/questions/70218560/creating-a-nested-object-from-entries + * + * @param nestedPaths + */ +export const generateObjectFromPaths = ( + object: Node, + [key, value]: [fieldName: string, dataType?: DataFieldDataType], +) => { + const keys = key.split('.'); + const last = keys.pop() ?? ''; + // eslint-disable-next-line no-return-assign, no-param-reassign + keys.reduce((o: any, k: string) => (o[k] ??= {}), object)[last] = getSyntaxKindType(value); + return object; +}; + +export const generateTypeNodeFromObject = (obj: Node): PropertySignature[] => { + return Object.keys(obj).map((key) => { + const child = obj[key]; + const value: TypeNode = + typeof child === 'object' && Object.keys(obj[key]).length + ? factory.createTypeLiteralNode(generateTypeNodeFromObject(child)) + : factory.createTypeReferenceNode(factory.createIdentifier('ValidationFunction'), [ + factory.createKeywordTypeNode(child as KeywordTypeSyntaxKind), + ]); + return factory.createPropertySignature( + undefined, + factory.createIdentifier(key), + factory.createToken(SyntaxKind.QuestionToken), + value, + ); + }); +}; + +/** + * + * export declare type MyPostCreateFormInputValues = { + caption: ValidationFunction; + username: ValidationFunction; + post_url: ValidationFunction; + profile_url: ValidationFunction; + status: ValidationFunction; + bio: { + favoriteQuote: ValidationFunction; + favoiteAnimal: { + genus: ValidationFunction; + } + }; + }; + * + * + * @param formName + * @param fieldConfigs + * @returns + */ +export const generateOnValidationType = (formName: string, fieldConfigs: Record) => { + const nestedPaths: [fieldName: string, dataType?: DataFieldDataType][] = []; + const typeNodes: TypeElement[] = []; + Object.entries(fieldConfigs).forEach(([fieldName, { dataType }]) => { + const hasNestedFieldPath = fieldName.split('.').length > 1; + if (hasNestedFieldPath) { + nestedPaths.push([fieldName, dataType]); + } else { + typeNodes.push( + factory.createPropertySignature( + undefined, + factory.createIdentifier(fieldName), + factory.createToken(SyntaxKind.QuestionToken), + factory.createTypeReferenceNode(factory.createIdentifier('ValidationFunction'), [ + factory.createKeywordTypeNode(getSyntaxKindType(dataType)), + ]), + ), + ); + } + }); + + if (nestedPaths.length) { + const nestedObj = nestedPaths.reduce(generateObjectFromPaths, {}); + const nestedTypeNodes = generateTypeNodeFromObject(nestedObj); + typeNodes.push(...nestedTypeNodes); + } + return factory.createTypeAliasDeclaration( + undefined, + [factory.createModifier(SyntaxKind.ExportKeyword), factory.createModifier(SyntaxKind.DeclareKeyword)], + factory.createIdentifier(getInputValuesTypeName(formName)), + undefined, + factory.createTypeLiteralNode(typeNodes), + ); +}; + +/** + * export declare type ValidationResponse = { + * hasError: boolean; + * errorMessage?: string; + * }; + */ +export const validationResponseType = factory.createTypeAliasDeclaration( + undefined, + [factory.createModifier(SyntaxKind.ExportKeyword), factory.createModifier(SyntaxKind.DeclareKeyword)], + factory.createIdentifier('ValidationResponse'), + undefined, + factory.createTypeLiteralNode([ + factory.createPropertySignature( + undefined, + factory.createIdentifier('hasError'), + undefined, + factory.createKeywordTypeNode(SyntaxKind.BooleanKeyword), + ), + factory.createPropertySignature( + undefined, + factory.createIdentifier('errorMessage'), + factory.createToken(SyntaxKind.QuestionToken), + factory.createKeywordTypeNode(SyntaxKind.StringKeyword), + ), + ]), +); + +/** + * onValidate?: {formTypeName} + * + * @param formName + * @returns + */ +export const buildOnValidateType = (formName: string) => { + return factory.createPropertySignature( + undefined, + factory.createIdentifier('onValidate'), + factory.createToken(SyntaxKind.QuestionToken), + factory.createTypeReferenceNode(factory.createIdentifier(getInputValuesTypeName(formName)), undefined), + ); +}; + +/** + * export declare type ValidationFunction = + * (value: T, validationResponse: ValidationResponse) => ValidationResponse | Promise; + */ +export const validationFunctionType = factory.createTypeAliasDeclaration( + undefined, + [factory.createModifier(SyntaxKind.ExportKeyword), factory.createModifier(SyntaxKind.DeclareKeyword)], + factory.createIdentifier('ValidationFunction'), + [factory.createTypeParameterDeclaration(factory.createIdentifier('T'), undefined, undefined)], + factory.createFunctionTypeNode( + undefined, + [ + factory.createParameterDeclaration( + undefined, + undefined, + undefined, + factory.createIdentifier('value'), + undefined, + factory.createTypeReferenceNode(factory.createIdentifier('T'), undefined), + undefined, + ), + factory.createParameterDeclaration( + undefined, + undefined, + undefined, + factory.createIdentifier('validationResponse'), + undefined, + factory.createTypeReferenceNode(factory.createIdentifier('ValidationResponse'), undefined), + undefined, + ), + ], + factory.createUnionTypeNode([ + factory.createTypeReferenceNode(factory.createIdentifier('ValidationResponse'), undefined), + factory.createTypeReferenceNode(factory.createIdentifier('Promise'), [ + factory.createTypeReferenceNode(factory.createIdentifier('ValidationResponse'), undefined), + ]), + ]), + ), +); diff --git a/packages/codegen-ui-react/lib/forms/typescript-type-map.ts b/packages/codegen-ui-react/lib/forms/typescript-type-map.ts index f3a0bb2e1..b7a504e7e 100644 --- a/packages/codegen-ui-react/lib/forms/typescript-type-map.ts +++ b/packages/codegen-ui-react/lib/forms/typescript-type-map.ts @@ -20,4 +20,5 @@ export const DATA_TYPE_TO_TYPESCRIPT_MAP: { [key: string]: KeywordTypeSyntaxKind Float: SyntaxKind.NumberKeyword, Boolean: SyntaxKind.BooleanKeyword, AWSTimestamp: SyntaxKind.NumberKeyword, + Object: SyntaxKind.ObjectKeyword, }; diff --git a/packages/codegen-ui-react/lib/imports/import-mapping.ts b/packages/codegen-ui-react/lib/imports/import-mapping.ts index 11524d878..6e6053ee2 100644 --- a/packages/codegen-ui-react/lib/imports/import-mapping.ts +++ b/packages/codegen-ui-react/lib/imports/import-mapping.ts @@ -46,6 +46,7 @@ export enum ImportValue { VALIDATE_FIELD = 'validateField', VALIDATE_FIELD_CODEGEN = 'validateField', FORMATTER = 'formatter', + FETCH_BY_PATH = 'fetchByPath', } export const ImportMapping: Record = { @@ -70,4 +71,5 @@ export const ImportMapping: Record = { [ImportValue.USE_EFFECT]: ImportSource.REACT, [ImportValue.FORMATTER]: ImportSource.UTILS, [ImportValue.VALIDATE_FIELD]: ImportSource.UTILS, + [ImportValue.FETCH_BY_PATH]: ImportSource.UTILS, }; diff --git a/packages/codegen-ui-react/lib/index.ts b/packages/codegen-ui-react/lib/index.ts index c21e41a25..25fd4db47 100644 --- a/packages/codegen-ui-react/lib/index.ts +++ b/packages/codegen-ui-react/lib/index.ts @@ -29,3 +29,4 @@ export * from './react-index-studio-template-renderer'; export * from './react-utils-studio-template-renderer'; export * from './react-required-dependency-provider'; export * from './utils/forms/validation'; +export { fetchByPath } from './utils/json-path-fetch'; diff --git a/packages/codegen-ui-react/lib/react-component-renderer.ts b/packages/codegen-ui-react/lib/react-component-renderer.ts index d8d0d7489..264c0d916 100644 --- a/packages/codegen-ui-react/lib/react-component-renderer.ts +++ b/packages/codegen-ui-react/lib/react-component-renderer.ts @@ -129,7 +129,7 @@ export class ReactComponentRenderer extends ComponentRendererBase< ]; if (this.componentMetadata.formMetadata) { - attributes.push(...addFormAttributes(this.component, this.componentMetadata)); + attributes.push(...addFormAttributes(this.component, this.componentMetadata.formMetadata)); } this.addPropsSpreadAttributes(attributes); diff --git a/packages/codegen-ui-react/lib/react-utils-studio-template-renderer.ts b/packages/codegen-ui-react/lib/react-utils-studio-template-renderer.ts index 76bec98cf..ac1409c79 100644 --- a/packages/codegen-ui-react/lib/react-utils-studio-template-renderer.ts +++ b/packages/codegen-ui-react/lib/react-utils-studio-template-renderer.ts @@ -22,9 +22,10 @@ import { ReactOutputManager } from './react-output-manager'; import { RequiredKeys } from './utils/type-utils'; import { transpile, buildPrinter, defaultRenderConfig } from './react-studio-template-renderer-helper'; import { generateValidationFunction } from './utils/forms/validation'; +import { getFetchByPathNodeFunction } from './utils/json-path-fetch'; import { generateFormatUtil } from './utils/string-formatter'; -export type UtilTemplateType = 'validation' | 'formatter'; +export type UtilTemplateType = 'validation' | 'formatter' | 'fetchByPath'; export class ReactUtilsStudioTemplateRenderer extends StudioTemplateRenderer< string, @@ -66,6 +67,8 @@ export class ReactUtilsStudioTemplateRenderer extends StudioTemplateRenderer< utilsStatements.push(...generateValidationFunction()); } else if (util === 'formatter') { utilsStatements.push(...generateFormatUtil()); + } else if (util === 'fetchByPath') { + utilsStatements.push(getFetchByPathNodeFunction()); } }); diff --git a/packages/codegen-ui-react/lib/utils/json-path-fetch.ts b/packages/codegen-ui-react/lib/utils/json-path-fetch.ts new file mode 100644 index 000000000..4dc9098b5 --- /dev/null +++ b/packages/codegen-ui-react/lib/utils/json-path-fetch.ts @@ -0,0 +1,232 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +import { factory, NodeFlags, SyntaxKind, VariableStatement } from 'typescript'; + +/** + * does not support array types within objects as it's currently not supported + * + * ref: https://stackoverflow.com/questions/45942118/lodash-return-array-of-values-if-the-path-is-valid + * + * @param input object input + * @param path dot notation path for the provided input + * @param accumlator array + * @returns returns value at the end of object + */ +export const fetchByPath = >(input: T, path: string, accumlator: any[] = []) => { + 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]; +}; + +/** + * get the generated output of the fetchByPath function in TS AST + * + * @returns VariableStatement + */ +export const getFetchByPathNodeFunction = (): VariableStatement => { + return factory.createVariableStatement( + [factory.createModifier(SyntaxKind.ExportKeyword)], + factory.createVariableDeclarationList( + [ + factory.createVariableDeclaration( + factory.createIdentifier('fetchByPath'), + undefined, + undefined, + factory.createArrowFunction( + undefined, + [ + factory.createTypeParameterDeclaration( + factory.createIdentifier('T'), + factory.createTypeReferenceNode(factory.createIdentifier('Record'), [ + factory.createKeywordTypeNode(SyntaxKind.StringKeyword), + factory.createKeywordTypeNode(SyntaxKind.AnyKeyword), + ]), + undefined, + ), + ], + [ + factory.createParameterDeclaration( + undefined, + undefined, + undefined, + factory.createIdentifier('input'), + undefined, + factory.createTypeReferenceNode(factory.createIdentifier('T'), undefined), + undefined, + ), + factory.createParameterDeclaration( + undefined, + undefined, + undefined, + factory.createIdentifier('path'), + undefined, + undefined, + factory.createStringLiteral(''), + ), + factory.createParameterDeclaration( + undefined, + undefined, + undefined, + factory.createIdentifier('accumlator'), + undefined, + factory.createArrayTypeNode(factory.createKeywordTypeNode(SyntaxKind.AnyKeyword)), + factory.createArrayLiteralExpression([], false), + ), + ], + undefined, + factory.createToken(SyntaxKind.EqualsGreaterThanToken), + factory.createBlock( + [ + factory.createVariableStatement( + undefined, + factory.createVariableDeclarationList( + [ + factory.createVariableDeclaration( + factory.createIdentifier('currentPath'), + undefined, + undefined, + factory.createCallExpression( + factory.createPropertyAccessExpression( + factory.createIdentifier('path'), + factory.createIdentifier('split'), + ), + undefined, + [factory.createStringLiteral('.')], + ), + ), + ], + NodeFlags.Const, + ), + ), + factory.createVariableStatement( + undefined, + factory.createVariableDeclarationList( + [ + factory.createVariableDeclaration( + factory.createIdentifier('head'), + undefined, + undefined, + factory.createCallExpression( + factory.createPropertyAccessExpression( + factory.createIdentifier('currentPath'), + factory.createIdentifier('shift'), + ), + undefined, + [], + ), + ), + ], + NodeFlags.Const, + ), + ), + factory.createIfStatement( + factory.createBinaryExpression( + factory.createBinaryExpression( + factory.createIdentifier('input'), + factory.createToken(SyntaxKind.AmpersandAmpersandToken), + factory.createIdentifier('head'), + ), + factory.createToken(SyntaxKind.AmpersandAmpersandToken), + factory.createBinaryExpression( + factory.createElementAccessExpression( + factory.createIdentifier('input'), + factory.createIdentifier('head'), + ), + factory.createToken(SyntaxKind.ExclamationEqualsEqualsToken), + factory.createIdentifier('undefined'), + ), + ), + factory.createBlock( + [ + factory.createIfStatement( + factory.createPrefixUnaryExpression( + SyntaxKind.ExclamationToken, + factory.createPropertyAccessExpression( + factory.createIdentifier('currentPath'), + factory.createIdentifier('length'), + ), + ), + factory.createBlock( + [ + factory.createExpressionStatement( + factory.createCallExpression( + factory.createPropertyAccessExpression( + factory.createIdentifier('accumlator'), + factory.createIdentifier('push'), + ), + undefined, + [ + factory.createElementAccessExpression( + factory.createIdentifier('input'), + factory.createIdentifier('head'), + ), + ], + ), + ), + ], + true, + ), + factory.createBlock( + [ + factory.createExpressionStatement( + factory.createCallExpression(factory.createIdentifier('fetchByPath'), undefined, [ + factory.createElementAccessExpression( + factory.createIdentifier('input'), + factory.createIdentifier('head'), + ), + factory.createCallExpression( + factory.createPropertyAccessExpression( + factory.createIdentifier('currentPath'), + factory.createIdentifier('join'), + ), + undefined, + [factory.createStringLiteral('.')], + ), + factory.createIdentifier('accumlator'), + ]), + ), + ], + true, + ), + ), + ], + true, + ), + undefined, + ), + factory.createReturnStatement( + factory.createElementAccessExpression( + factory.createIdentifier('accumlator'), + factory.createNumericLiteral('0'), + ), + ), + ], + true, + ), + ), + ), + ], + NodeFlags.Const, + ), + ); +}; diff --git a/packages/codegen-ui-react/package.json b/packages/codegen-ui-react/package.json index d9b4c0aa8..7bc02a19b 100644 --- a/packages/codegen-ui-react/package.json +++ b/packages/codegen-ui-react/package.json @@ -18,6 +18,12 @@ "build": "tsc -p tsconfig.build.json", "build:watch": "npm run build -- --watch" }, + "exports": { + "./json-path-fetch": { + "import": "./dist/lib/utils/json-path-fetch.js", + "require": "./dist/lib/utils/json-path-fetch.js" + } + }, "devDependencies": { "@aws-amplify/ui-react": "^2.1.0", "@types/node": "^16.3.3", diff --git a/packages/codegen-ui/example-schemas/forms/bio-nested-create.json b/packages/codegen-ui/example-schemas/forms/bio-nested-create.json new file mode 100644 index 000000000..378d0bcb9 --- /dev/null +++ b/packages/codegen-ui/example-schemas/forms/bio-nested-create.json @@ -0,0 +1,59 @@ +{ + "cta" : { }, + "dataType" : { + "dataSourceType" : "Custom", + "dataTypeName" : "JSON" + }, + "fields" : { + "firstName" : { + "inputType" : { + "type" : "TextField" + }, + "label" : "firstName", + "position" : { + "fixed" : "first" + } + }, + "lastName" : { + "inputType" : { + "type" : "TextField" + }, + "label" : "lastName", + "position" : { + "below" : "firstName" + } + }, + "bio.favoriteQuote" : { + "inputType" : { + "type" : "TextField" + }, + "label" : "favoriteQuote", + "position" : { + "below" : "bio" + } + }, + "bio.favoriteAnimal" : { + "inputType" : { + "type" : "TextField" + }, + "label" : "favoriteAnimal", + "position" : { + "below" : "bio.favoriteQuote" + } + } + }, + "formActionType" : "create", + "name" : "NestedJson", + "sectionalElements" : { + "bio" : { + "level" : 3, + "position" : { + "below" : "lastName" + }, + "text" : "bio", + "type" : "Heading" + } + }, + "style" : { }, + "id" : "f-BD6Fl4FX8B6XL7Il9a" +} \ No newline at end of file diff --git a/packages/codegen-ui/lib/types/form/form-metadata.ts b/packages/codegen-ui/lib/types/form/form-metadata.ts index e923cdd2f..2c2885ae8 100644 --- a/packages/codegen-ui/lib/types/form/form-metadata.ts +++ b/packages/codegen-ui/lib/types/form/form-metadata.ts @@ -17,7 +17,6 @@ import { DataFieldDataType } from '../data'; import { FieldValidationConfiguration } from './form-validation'; export type FieldConfigMetadata = { - hasChange: boolean; // ex. name field has a string validation type where the rule is char length > 5 validationRules: FieldValidationConfiguration[]; // component field is of type AWSTimestamp will need to map this to date then get time from date diff --git a/packages/codegen-ui/lib/utils/form-component-metadata.ts b/packages/codegen-ui/lib/utils/form-component-metadata.ts index f00f95e96..8a1a3fbbf 100644 --- a/packages/codegen-ui/lib/utils/form-component-metadata.ts +++ b/packages/codegen-ui/lib/utils/form-component-metadata.ts @@ -39,7 +39,6 @@ export const mapFormMetadata = (form: StudioForm, formDefinition: FormDefinition fieldConfigs: inputElementEntries.reduce>((configs, [name, config]) => { const updatedConfigs = configs; const metadata: FieldConfigMetadata = { - hasChange: true, validationRules: [], isArray: config.componentType === 'ArrayField', }; @@ -53,6 +52,7 @@ export const mapFormMetadata = (form: StudioForm, formDefinition: FormDefinition if ('dataType' in config && config.dataType) { metadata.dataType = config.dataType; } + updatedConfigs[name] = metadata; return updatedConfigs; }, {}),