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 8ea16185..61bf022a 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 @@ -1,5 +1,666 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`amplify form renderer tests GraphQL form tests should generate a create form 1`] = ` +"/* eslint-disable */ +import * as React from \\"react\\"; +import { + Badge, + Button, + Divider, + Flex, + Grid, + GridProps, + Icon, + ScrollView, + Text, + TextAreaField, + TextAreaFieldProps, + TextField, + TextFieldProps, + useTheme, +} from \\"@aws-amplify/ui-react\\"; +import { + EscapeHatchProps, + getOverrideProps, +} from \\"@aws-amplify/ui-react/internal\\"; +import { Post } from \\"../API\\"; +import { fetchByPath, validateField } from \\"./utils\\"; +import { createPost } from \\"../graphql/mutations\\"; +import { API } from \\"@aws-amplify/api\\"; + +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; + metadata?: string; + profile_url?: string; + nonModelField?: string; + nonModelFieldArray?: string[]; +}; +export declare type MyPostFormValidationValues = { + caption?: ValidationFunction; + username?: ValidationFunction; + post_url?: ValidationFunction; + metadata?: ValidationFunction; + profile_url?: ValidationFunction; + nonModelField?: ValidationFunction; + nonModelFieldArray?: ValidationFunction; +}; +export declare type PrimitiveOverrideProps = Partial & + React.DOMAttributes; +export declare type MyPostFormOverridesProps = { + MyPostFormGrid?: PrimitiveOverrideProps; + caption?: PrimitiveOverrideProps; + username?: PrimitiveOverrideProps; + post_url?: PrimitiveOverrideProps; + metadata?: PrimitiveOverrideProps; + profile_url?: PrimitiveOverrideProps; + nonModelField?: PrimitiveOverrideProps; + nonModelFieldArray?: PrimitiveOverrideProps; +} & EscapeHatchProps; +export type MyPostFormProps = React.PropsWithChildren< + { + overrides?: MyPostFormOverridesProps | undefined | null; + } & { + clearOnSuccess?: boolean; + onSubmit?: (fields: MyPostFormInputValues) => MyPostFormInputValues; + onSuccess?: (fields: MyPostFormInputValues) => void; + onError?: (fields: MyPostFormInputValues, errorMessage: string) => void; + onCancel?: () => void; + onChange?: (fields: MyPostFormInputValues) => MyPostFormInputValues; + onValidate?: MyPostFormValidationValues; + } & React.CSSProperties +>; +function ArrayField({ + items = [], + onChange, + label, + inputFieldRef, + children, + hasError, + setFieldValue, + currentFieldValue, + defaultFieldValue, + lengthLimit, + getBadgeText, + errorMessage, +}) { + const labelElement = {label}; + const { + tokens: { + components: { + fieldmessages: { error: errorStyles }, + }, + }, + } = useTheme(); + const [selectedBadgeIndex, setSelectedBadgeIndex] = React.useState(); + const [isEditing, setIsEditing] = React.useState(); + React.useEffect(() => { + if (isEditing) { + inputFieldRef?.current?.focus(); + } + }, [isEditing]); + const removeItem = async (removeIndex) => { + const newItems = items.filter((value, index) => index !== removeIndex); + await onChange(newItems); + setSelectedBadgeIndex(undefined); + }; + const addItem = async () => { + if ( + currentFieldValue !== undefined && + currentFieldValue !== null && + currentFieldValue !== \\"\\" && + !hasError + ) { + const newItems = [...items]; + if (selectedBadgeIndex !== undefined) { + newItems[selectedBadgeIndex] = currentFieldValue; + setSelectedBadgeIndex(undefined); + } else { + newItems.push(currentFieldValue); + } + await onChange(newItems); + setIsEditing(false); + } + }; + const arraySection = ( + + {!!items?.length && ( + + {items.map((value, index) => { + return ( + { + setSelectedBadgeIndex(index); + setFieldValue(items[index]); + setIsEditing(true); + }} + > + {getBadgeText ? getBadgeText(value) : value.toString()} + { + event.stopPropagation(); + removeItem(index); + }} + /> + + ); + })} + + )} + + + ); + if (lengthLimit !== undefined && items.length >= lengthLimit && !isEditing) { + return ( + + {labelElement} + {arraySection} + + ); + } + return ( + + {labelElement} + {isEditing && children} + {!isEditing ? ( + <> + + {errorMessage && hasError && ( + + {errorMessage} + + )} + + ) : ( + + {(currentFieldValue || isEditing) && ( + + )} + + + )} + {arraySection} + + ); +} +export default function MyPostForm(props: MyPostFormProps): React.ReactElement { + const { + clearOnSuccess = true, + onSuccess, + onError, + onSubmit, + onCancel, + onValidate, + onChange, + overrides, + ...rest + } = props; + const initialValues = { + caption: \\"\\", + username: \\"\\", + post_url: \\"\\", + metadata: \\"\\", + profile_url: \\"\\", + nonModelField: \\"\\", + nonModelFieldArray: [], + }; + const [caption, setCaption] = React.useState(initialValues.caption); + const [username, setUsername] = React.useState(initialValues.username); + const [post_url, setPost_url] = React.useState(initialValues.post_url); + const [metadata, setMetadata] = React.useState(initialValues.metadata); + const [profile_url, setProfile_url] = React.useState( + initialValues.profile_url + ); + const [nonModelField, setNonModelField] = React.useState( + initialValues.nonModelField + ); + const [nonModelFieldArray, setNonModelFieldArray] = React.useState( + initialValues.nonModelFieldArray + ); + const [errors, setErrors] = React.useState({}); + const resetStateValues = () => { + setCaption(initialValues.caption); + setUsername(initialValues.username); + setPost_url(initialValues.post_url); + setMetadata(initialValues.metadata); + setProfile_url(initialValues.profile_url); + setNonModelField(initialValues.nonModelField); + setNonModelFieldArray(initialValues.nonModelFieldArray); + setCurrentNonModelFieldArrayValue(\\"\\"); + setErrors({}); + }; + const [currentNonModelFieldArrayValue, setCurrentNonModelFieldArrayValue] = + React.useState(\\"\\"); + const nonModelFieldArrayRef = React.createRef(); + const validations = { + caption: [], + username: [], + post_url: [{ type: \\"URL\\" }], + metadata: [{ type: \\"JSON\\" }], + profile_url: [{ type: \\"URL\\" }], + nonModelField: [{ type: \\"JSON\\" }], + nonModelFieldArray: [{ type: \\"JSON\\" }], + }; + const runValidationTasks = async ( + fieldName, + currentValue, + getDisplayValue + ) => { + const value = + currentValue && getDisplayValue + ? getDisplayValue(currentValue) + : currentValue; + let validationResponse = validateField(value, validations[fieldName]); + const customValidator = fetchByPath(onValidate, fieldName); + if (customValidator) { + validationResponse = await customValidator(value, validationResponse); + } + setErrors((errors) => ({ ...errors, [fieldName]: validationResponse })); + return validationResponse; + }; + return ( + /* @ts-ignore: TS2322 */ + { + event.preventDefault(); + let modelFields = { + caption, + username, + post_url, + metadata, + profile_url, + nonModelField, + nonModelFieldArray, + }; + const validationResponses = await Promise.all( + Object.keys(validations).reduce((promises, fieldName) => { + if (Array.isArray(modelFields[fieldName])) { + promises.push( + ...modelFields[fieldName].map((item) => + runValidationTasks(fieldName, item) + ) + ); + return promises; + } + promises.push( + runValidationTasks(fieldName, modelFields[fieldName]) + ); + return promises; + }, []) + ); + if (validationResponses.some((r) => r.hasError)) { + return; + } + if (onSubmit) { + modelFields = onSubmit(modelFields); + } + try { + Object.entries(modelFields).forEach(([key, value]) => { + if (typeof value === \\"string\\" && value.trim() === \\"\\") { + modelFields[key] = undefined; + } + }); + const modelFieldsToSave = { + caption: modelFields.caption, + username: modelFields.username, + post_url: modelFields.post_url, + metadata: modelFields.metadata, + profile_url: modelFields.profile_url, + nonModelFieldArray: modelFields.nonModelFieldArray.map((s) => + JSON.parse(s) + ), + nonModelField: modelFields.nonModelField + ? JSON.parse(modelFields.nonModelField) + : modelFields.nonModelField, + }; + await API.graphql({ + query: createPost, + variables: { input: modelFieldsToSave }, + }); + if (onSuccess) { + onSuccess(modelFields); + } + if (clearOnSuccess) { + resetStateValues(); + } + } catch (err) { + if (onError) { + onError(modelFields, err.message); + } + } + }} + {...getOverrideProps(overrides, \\"MyPostForm\\")} + {...rest} + > + + + + + + + + { + let { value } = e.target; + if (onChange) { + const modelFields = { + caption: value, + username, + post_url, + metadata, + profile_url, + nonModelField, + nonModelFieldArray, + }; + const result = onChange(modelFields); + value = result?.caption ?? value; + } + if (errors.caption?.hasError) { + runValidationTasks(\\"caption\\", value); + } + setCaption(value); + }} + onBlur={() => runValidationTasks(\\"caption\\", caption)} + errorMessage={errors.caption?.errorMessage} + hasError={errors.caption?.hasError} + {...getOverrideProps(overrides, \\"caption\\")} + > + { + let { value } = e.target; + if (onChange) { + const modelFields = { + caption, + username: value, + post_url, + metadata, + profile_url, + nonModelField, + nonModelFieldArray, + }; + const result = onChange(modelFields); + value = result?.username ?? value; + } + if (errors.username?.hasError) { + runValidationTasks(\\"username\\", value); + } + setUsername(value); + }} + onBlur={() => runValidationTasks(\\"username\\", username)} + errorMessage={errors.username?.errorMessage} + hasError={errors.username?.hasError} + {...getOverrideProps(overrides, \\"username\\")} + > + { + let { value } = e.target; + if (onChange) { + const modelFields = { + caption, + username, + post_url: value, + metadata, + profile_url, + nonModelField, + nonModelFieldArray, + }; + const result = onChange(modelFields); + value = result?.post_url ?? value; + } + if (errors.post_url?.hasError) { + runValidationTasks(\\"post_url\\", value); + } + setPost_url(value); + }} + onBlur={() => runValidationTasks(\\"post_url\\", post_url)} + errorMessage={errors.post_url?.errorMessage} + hasError={errors.post_url?.hasError} + {...getOverrideProps(overrides, \\"post_url\\")} + > + { + let { value } = e.target; + if (onChange) { + const modelFields = { + caption, + username, + post_url, + metadata: value, + profile_url, + nonModelField, + nonModelFieldArray, + }; + const result = onChange(modelFields); + value = result?.metadata ?? value; + } + if (errors.metadata?.hasError) { + runValidationTasks(\\"metadata\\", value); + } + setMetadata(value); + }} + onBlur={() => runValidationTasks(\\"metadata\\", metadata)} + errorMessage={errors.metadata?.errorMessage} + hasError={errors.metadata?.hasError} + {...getOverrideProps(overrides, \\"metadata\\")} + > + { + let { value } = e.target; + if (onChange) { + const modelFields = { + caption, + username, + post_url, + metadata, + profile_url: value, + nonModelField, + nonModelFieldArray, + }; + const result = onChange(modelFields); + value = result?.profile_url ?? value; + } + if (errors.profile_url?.hasError) { + runValidationTasks(\\"profile_url\\", value); + } + setProfile_url(value); + }} + onBlur={() => runValidationTasks(\\"profile_url\\", profile_url)} + errorMessage={errors.profile_url?.errorMessage} + hasError={errors.profile_url?.hasError} + {...getOverrideProps(overrides, \\"profile_url\\")} + > + { + let { value } = e.target; + if (onChange) { + const modelFields = { + caption, + username, + post_url, + metadata, + profile_url, + nonModelField: value, + nonModelFieldArray, + }; + const result = onChange(modelFields); + value = result?.nonModelField ?? value; + } + if (errors.nonModelField?.hasError) { + runValidationTasks(\\"nonModelField\\", value); + } + setNonModelField(value); + }} + onBlur={() => runValidationTasks(\\"nonModelField\\", nonModelField)} + errorMessage={errors.nonModelField?.errorMessage} + hasError={errors.nonModelField?.hasError} + {...getOverrideProps(overrides, \\"nonModelField\\")} + > + { + let values = items; + if (onChange) { + const modelFields = { + caption, + username, + post_url, + metadata, + profile_url, + nonModelField, + nonModelFieldArray: values, + }; + const result = onChange(modelFields); + values = result?.nonModelFieldArray ?? values; + } + setNonModelFieldArray(values); + setCurrentNonModelFieldArrayValue(\\"\\"); + }} + currentFieldValue={currentNonModelFieldArrayValue} + label={\\"Non model field array\\"} + items={nonModelFieldArray} + hasError={errors?.nonModelFieldArray?.hasError} + errorMessage={errors?.nonModelFieldArray?.errorMessage} + setFieldValue={setCurrentNonModelFieldArrayValue} + inputFieldRef={nonModelFieldArrayRef} + defaultFieldValue={\\"\\"} + > + { + let { value } = e.target; + if (errors.nonModelFieldArray?.hasError) { + runValidationTasks(\\"nonModelFieldArray\\", value); + } + setCurrentNonModelFieldArrayValue(value); + }} + onBlur={() => + runValidationTasks( + \\"nonModelFieldArray\\", + currentNonModelFieldArrayValue + ) + } + errorMessage={errors.nonModelFieldArray?.errorMessage} + hasError={errors.nonModelFieldArray?.hasError} + ref={nonModelFieldArrayRef} + labelHidden={true} + {...getOverrideProps(overrides, \\"nonModelFieldArray\\")} + > + + + ); +} +" +`; + exports[`amplify form renderer tests datastore form tests custom form tests should render a create form for child of 1:m relationship 1`] = ` "/* eslint-disable */ import * as React from \\"react\\"; 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 665ddfd7..83cdcc65 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 @@ -15,7 +15,11 @@ */ /* eslint-disable no-template-curly-in-string */ import { ImportSource } from '../imports'; -import { generateComponentOnlyWithAmplifyFormRenderer, generateWithAmplifyFormRenderer } from './__utils__'; +import { + generateComponentOnlyWithAmplifyFormRenderer, + generateWithAmplifyFormRenderer, + rendererConfigWithGraphQL, +} from './__utils__'; describe('amplify form renderer tests', () => { describe('datastore form tests', () => { @@ -672,6 +676,19 @@ describe('amplify form renderer tests', () => { }); }); + describe('GraphQL form tests', () => { + it('should generate a create form', () => { + const { componentText } = generateWithAmplifyFormRenderer( + 'forms/post-datastore-create', + 'datastore/post', + rendererConfigWithGraphQL, + { isNonModelSupported: true, isRelationshipSupported: true }, + ); + + expect(componentText).toMatchSnapshot(); + }); + }); + it('should render form for child of bidirectional 1:m when field defined on parent', () => { const { componentText, declaration } = generateWithAmplifyFormRenderer( 'forms/car-datastore-update', diff --git a/packages/codegen-ui-react/lib/amplify-ui-renderers/amplify-form-renderer.ts b/packages/codegen-ui-react/lib/amplify-ui-renderers/amplify-form-renderer.ts index 7f6eeb9c..33849867 100644 --- a/packages/codegen-ui-react/lib/amplify-ui-renderers/amplify-form-renderer.ts +++ b/packages/codegen-ui-react/lib/amplify-ui-renderers/amplify-form-renderer.ts @@ -138,6 +138,7 @@ export class AmplifyFormRenderer extends ReactFormTemplateRenderer { this.component, this.componentMetadata, this.importCollection, + this.renderConfig, parent, ).renderElement(renderChildren); @@ -189,6 +190,7 @@ export class AmplifyFormRenderer extends ReactFormTemplateRenderer { this.component, this.componentMetadata, this.importCollection, + this.renderConfig, parent, ).renderElement(renderChildren); } 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 96ed317a..323b52a4 100644 --- a/packages/codegen-ui-react/lib/amplify-ui-renderers/form.ts +++ b/packages/codegen-ui-react/lib/amplify-ui-renderers/form.ts @@ -25,13 +25,15 @@ import { import { factory, JsxAttribute, JsxChild, JsxElement, JsxOpeningElement, Statement, SyntaxKind } from 'typescript'; import { ReactComponentRenderer } from '../react-component-renderer'; import { buildFormLayoutProperties, buildOpeningElementProperties } from '../react-component-render-helper'; -import { ImportCollection, ImportSource } from '../imports'; -import { buildDataStoreExpression } from '../forms'; +import { ImportCollection, ImportSource, ImportValue } from '../imports'; +import { buildExpression } from '../forms'; import { onSubmitValidationRun, buildModelFieldObject } from '../forms/form-renderer-helper'; import { hasTokenReference } from '../utils/forms/layout-helpers'; import { resetFunctionCheck } from '../forms/form-renderer-helper/value-props'; import { isModelDataType } from '../forms/form-renderer-helper/render-checkers'; import { replaceEmptyStringStatement } from '../forms/form-renderer-helper/cta-props'; +import { ReactRenderConfig } from '../react-render-config'; +import { defaultRenderConfig } from '../react-studio-template-renderer-helper'; export default class FormRenderer extends ReactComponentRenderer { constructor( @@ -39,6 +41,7 @@ export default class FormRenderer extends ReactComponentRenderer { + if (dataApi === 'GraphQL') { + const createMutation = `create${importedModelName}`; + + return factory.createCallExpression( + factory.createPropertyAccessExpression(factory.createIdentifier('API'), factory.createIdentifier('graphql')), + undefined, + [ + factory.createObjectLiteralExpression( + [ + factory.createPropertyAssignment( + factory.createIdentifier('query'), + factory.createIdentifier(importCollection.addGraphqlMutationImport(createMutation)), + ), + factory.createPropertyAssignment( + factory.createIdentifier('variables'), + factory.createObjectLiteralExpression( + [ + factory.createPropertyAssignment( + factory.createIdentifier('input'), + factory.createIdentifier(savedObjectName), + ), + ], + false, + ), + ), + ], + true, + ), + ], + ); + } + return factory.createCallExpression( factory.createPropertyAccessExpression(factory.createIdentifier('DataStore'), factory.createIdentifier('save')), undefined, @@ -211,13 +248,14 @@ export const replaceEmptyStringStatement = factory.createExpressionStatement( ), ); -export const buildDataStoreExpression = ( +export const buildExpression = ( dataStoreActionType: 'update' | 'create', modelName: string, importedModelName: string, fieldConfigs: Record, dataSchema: GenericDataSchema, importCollection: ImportCollection, + dataApi: DataApiKind = 'DataStore', ): Statement[] => { const modelFieldsObjectName = 'modelFields'; const modelFieldsObjectToSaveName = 'modelFieldsToSave'; @@ -314,9 +352,11 @@ export const buildDataStoreExpression = ( ); } - const recordCreateDataStoreCallExpression = getRecordCreateDataStoreCallExpression({ + const recordCreateCallExpression = getRecordCreateCallExpression({ savedObjectName, importedModelName, + importCollection, + dataApi, }); const genericCreateStatement = relationshipsPromisesAccessStatements.length ? [ @@ -328,14 +368,14 @@ export const buildDataStoreExpression = ( factory.createIdentifier(savedRecordName), undefined, undefined, - factory.createAwaitExpression(recordCreateDataStoreCallExpression), + factory.createAwaitExpression(recordCreateCallExpression), ), ], NodeFlags.Const, ), ), ] - : [factory.createExpressionStatement(factory.createAwaitExpression(recordCreateDataStoreCallExpression))]; + : [factory.createExpressionStatement(factory.createAwaitExpression(recordCreateCallExpression))]; const resolvePromisesStatement = factory.createExpressionStatement( factory.createAwaitExpression( diff --git a/packages/codegen-ui-react/lib/forms/form-renderer-helper/index.ts b/packages/codegen-ui-react/lib/forms/form-renderer-helper/index.ts index 20036321..200f1b7f 100644 --- a/packages/codegen-ui-react/lib/forms/form-renderer-helper/index.ts +++ b/packages/codegen-ui-react/lib/forms/form-renderer-helper/index.ts @@ -13,7 +13,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -export { onSubmitValidationRun, buildUpdateDatastoreQuery, buildDataStoreExpression } from './cta-props'; +export { onSubmitValidationRun, buildUpdateDatastoreQuery, buildExpression } from './cta-props'; export { buildModelFieldObject } from './model-fields'; diff --git a/packages/codegen-ui-react/lib/forms/index.ts b/packages/codegen-ui-react/lib/forms/index.ts index 35d30a3a..570d511f 100644 --- a/packages/codegen-ui-react/lib/forms/index.ts +++ b/packages/codegen-ui-react/lib/forms/index.ts @@ -13,6 +13,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -export { buildDataStoreExpression, addFormAttributes } from './form-renderer-helper'; +export { buildExpression, addFormAttributes } from './form-renderer-helper'; export * from './react-form-renderer'; export * from './form-renderer-helper/type-helper'; 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 be591b91..e5345302 100644 --- a/packages/codegen-ui-react/lib/forms/react-form-renderer.ts +++ b/packages/codegen-ui-react/lib/forms/react-form-renderer.ts @@ -419,7 +419,7 @@ export abstract class ReactFormTemplateRenderer extends StudioTemplateRenderer< ), ]; - // add binding elments to statements + // add binding elements to statements statements.push( factory.createVariableStatement( undefined,