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 a534931bd..c40cab271 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 @@ -12553,6 +12553,523 @@ export default function CreateOwnerForm(props: CreateOwnerFormProps): React.Reac " `; +exports[`amplify form renderer tests NoApi form tests should render custom data form successfully with no configured API 1`] = ` +"/* eslint-disable */ +import * as React from \\"react\\"; +import { + Autocomplete, + Badge, + Button, + Divider, + Flex, + Grid, + Icon, + ScrollView, + Text, + TextField, + useTheme, +} from \\"@aws-amplify/ui-react\\"; +import { getOverrideProps } from \\"@aws-amplify/ui-react/internal\\"; +import { fetchByPath, validateField } from \\"./utils\\"; +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 CustomDataForm(props) { + const { onSubmit, onCancel, onValidate, onChange, overrides, ...rest } = + props; + const initialValues = { + name: \\"John Doe\\", + email: [\\"johndoe@amplify.com\\"], + phone: [\\"+1-401-152-6995\\"], + city: undefined, + }; + const [name, setName] = React.useState(initialValues.name); + const [email, setEmail] = React.useState(initialValues.email); + const [phone, setPhone] = React.useState(initialValues.phone); + const [city, setCity] = React.useState(initialValues.city); + const [errors, setErrors] = React.useState({}); + const resetStateValues = () => { + setName(initialValues.name); + setEmail(initialValues.email); + setCurrentEmailValue(\\"\\"); + setPhone(initialValues.phone); + setCurrentPhoneValue(\\"\\"); + setCity(initialValues.city); + setErrors({}); + }; + const [currentEmailValue, setCurrentEmailValue] = React.useState(\\"\\"); + const emailRef = React.createRef(); + const [currentPhoneValue, setCurrentPhoneValue] = React.useState(\\"\\"); + const phoneRef = React.createRef(); + const validations = { + name: [{ type: \\"Required\\" }], + email: [{ type: \\"Required\\" }], + phone: [{ type: \\"Required\\" }, { type: \\"Phone\\" }], + city: [], + }; + 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 ( + { + event.preventDefault(); + const modelFields = { + name, + email, + phone, + city, + }; + const validationResponses = await Promise.all( + Object.keys(validations).reduce((promises, fieldName) => { + if (Array.isArray(modelFields[fieldName])) { + promises.push( + ...modelFields[fieldName].map((item) => + runValidationTasks(fieldName, item) + ) + ); + return promises; + } + promises.push( + runValidationTasks(fieldName, modelFields[fieldName]) + ); + return promises; + }, []) + ); + if (validationResponses.some((r) => r.hasError)) { + return; + } + await onSubmit(modelFields); + }} + {...getOverrideProps(overrides, \\"CustomDataForm\\")} + {...rest} + > + + name + * + + } + isRequired={true} + value={name} + onChange={(e) => { + let { value } = e.target; + if (onChange) { + const modelFields = { + name: value, + email, + phone, + city, + }; + const result = onChange(modelFields); + value = result?.name ?? value; + } + if (errors.name?.hasError) { + runValidationTasks(\\"name\\", value); + } + setName(value); + }} + onBlur={() => runValidationTasks(\\"name\\", name)} + errorMessage={errors.name?.errorMessage} + hasError={errors.name?.hasError} + {...getOverrideProps(overrides, \\"name\\")} + > + { + let values = items; + if (onChange) { + const modelFields = { + name, + email: values, + phone, + city, + }; + const result = onChange(modelFields); + values = result?.email ?? values; + } + setEmail(values); + setCurrentEmailValue(\\"\\"); + }} + currentFieldValue={currentEmailValue} + label={ + + E-mail + * + + } + items={email} + hasError={errors?.email?.hasError} + errorMessage={errors?.email?.errorMessage} + setFieldValue={setCurrentEmailValue} + inputFieldRef={emailRef} + defaultFieldValue={\\"\\"} + > + { + let { value } = e.target; + if (errors.email?.hasError) { + runValidationTasks(\\"email\\", value); + } + setCurrentEmailValue(value); + }} + onBlur={() => runValidationTasks(\\"email\\", currentEmailValue)} + errorMessage={errors.email?.errorMessage} + hasError={errors.email?.hasError} + ref={emailRef} + labelHidden={true} + {...getOverrideProps(overrides, \\"email\\")} + > + + { + let values = items; + if (onChange) { + const modelFields = { + name, + email, + phone: values, + city, + }; + const result = onChange(modelFields); + values = result?.phone ?? values; + } + setPhone(values); + setCurrentPhoneValue(\\"\\"); + }} + currentFieldValue={currentPhoneValue} + label={ + + phone + * + + } + items={phone} + hasError={errors?.phone?.hasError} + errorMessage={errors?.phone?.errorMessage} + setFieldValue={setCurrentPhoneValue} + inputFieldRef={phoneRef} + defaultFieldValue={\\"\\"} + > + { + let { value } = e.target; + if (errors.phone?.hasError) { + runValidationTasks(\\"phone\\", value); + } + setCurrentPhoneValue(value); + }} + onBlur={() => runValidationTasks(\\"phone\\", currentPhoneValue)} + errorMessage={errors.phone?.errorMessage} + hasError={errors.phone?.hasError} + ref={phoneRef} + labelHidden={true} + {...getOverrideProps(overrides, \\"phone\\")} + > + + { + setCity(id); + runValidationTasks(\\"city\\", id); + }} + onClear={() => { + setCity(\\"\\"); + }} + onChange={(e) => { + let { value } = e.target; + if (onChange) { + const modelFields = { + name, + email, + phone, + city: value, + }; + const result = onChange(modelFields); + value = result?.city ?? value; + } + if (errors.city?.hasError) { + runValidationTasks(\\"city\\", value); + } + setCity(value); + }} + onBlur={() => runValidationTasks(\\"city\\", city)} + errorMessage={errors.city?.errorMessage} + hasError={errors.city?.hasError} + labelHidden={false} + {...getOverrideProps(overrides, \\"city\\")} + > + + + + + + + + + ); +} +" +`; + +exports[`amplify form renderer tests NoApi form tests should render custom data form successfully with no configured API 2`] = ` +"import * as React from \\"react\\"; +import { AutocompleteProps, GridProps, TextFieldProps } from \\"@aws-amplify/ui-react\\"; +import { EscapeHatchProps } from \\"@aws-amplify/ui-react/internal\\"; +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[]; + phone?: string[]; + city?: string; +}; +export declare type CustomDataFormValidationValues = { + name?: ValidationFunction; + email?: ValidationFunction; + phone?: ValidationFunction; + city?: ValidationFunction; +}; +export declare type PrimitiveOverrideProps = Partial & React.DOMAttributes; +export declare type CustomDataFormOverridesProps = { + CustomDataFormGrid?: PrimitiveOverrideProps; + name?: PrimitiveOverrideProps; + email?: PrimitiveOverrideProps; + phone?: PrimitiveOverrideProps; + city?: PrimitiveOverrideProps; +} & EscapeHatchProps; +export declare type CustomDataFormProps = React.PropsWithChildren<{ + overrides?: CustomDataFormOverridesProps | undefined | null; +} & { + onSubmit: (fields: CustomDataFormInputValues) => void; + onCancel?: () => void; + onChange?: (fields: CustomDataFormInputValues) => CustomDataFormInputValues; + onValidate?: CustomDataFormValidationValues; +} & React.CSSProperties>; +export default function CustomDataForm(props: CustomDataFormProps): React.ReactElement; +" +`; + 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__/__snapshots__/studio-ui-codegen-react.test.ts.snap b/packages/codegen-ui-react/lib/__tests__/__snapshots__/studio-ui-codegen-react.test.ts.snap index d726ab447..fb10f6743 100644 --- a/packages/codegen-ui-react/lib/__tests__/__snapshots__/studio-ui-codegen-react.test.ts.snap +++ b/packages/codegen-ui-react/lib/__tests__/__snapshots__/studio-ui-codegen-react.test.ts.snap @@ -10248,6 +10248,43 @@ export default function TextFieldPrimitive( " `; +exports[`amplify render tests renderer configurations with NoApi should render component without data binding successfully 1`] = ` +"/* eslint-disable */ +import * as React from \\"react\\"; +import { + EscapeHatchProps, + getOverrideProps, +} from \\"@aws-amplify/ui-react/internal\\"; +import { Button, ButtonProps } from \\"@aws-amplify/ui-react\\"; + +export declare type PrimitiveOverrideProps = Partial & + React.DOMAttributes; +export declare type CustomButtonOverridesProps = { + CustomButton?: PrimitiveOverrideProps; +} & EscapeHatchProps; +export type CustomButtonProps = React.PropsWithChildren< + Partial & { + overrides?: CustomButtonOverridesProps | undefined | null; + } +>; +export default function CustomButton( + props: CustomButtonProps +): React.ReactElement { + const { overrides, ...rest } = props; + return ( + /* @ts-ignore: TS2322 */ + + ); +} +" +`; + exports[`amplify render tests sample code snippet tests should generate a sample code snippet for components 1`] = ` "/* eslint-disable */ import * as React from \\"react\\"; 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 3ff813c2f..416e33977 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 @@ -167,3 +167,9 @@ export const rendererConfigWithGraphQL: ReactRenderConfig = { fragmentsFilePath: '../graphql/fragments', }, }; + +export const rendererConfigWithNoApi: ReactRenderConfig = { + apiConfiguration: { + dataApi: 'NoApi', + }, +}; 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 95019e99e..2d2dc672f 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 @@ -14,6 +14,7 @@ limitations under the License. */ /* eslint-disable no-template-curly-in-string */ +import { NoApiError } from '@aws-amplify/codegen-ui'; import { ImportSource } from '../imports'; import { ReactRenderConfig } from '../react-render-config'; import { @@ -21,6 +22,7 @@ import { generateComponentOnlyWithAmplifyFormRenderer, generateWithAmplifyFormRenderer, rendererConfigWithGraphQL, + rendererConfigWithNoApi, } from './__utils__'; describe('amplify form renderer tests', () => { @@ -1025,6 +1027,32 @@ describe('amplify form renderer tests', () => { }); }); + describe('NoApi form tests', () => { + it('should throw if form has data dependency with no configured API', () => { + expect(() => { + generateWithAmplifyFormRenderer( + 'forms/comment-datastore-create', + 'datastore/comment-hasMany-belongsTo-relationships', + { ...defaultCLIRenderConfig, ...rendererConfigWithNoApi }, + { isNonModelSupported: true, isRelationshipSupported: true }, + ); + }).toThrow(NoApiError); + }); + + it('should render custom data form successfully with no configured API', () => { + const { componentText, declaration } = generateWithAmplifyFormRenderer( + 'forms/custom-with-array-field', + undefined, + { ...defaultCLIRenderConfig, ...rendererConfigWithNoApi }, + { isNonModelSupported: true, isRelationshipSupported: true }, + ); + expect(componentText).not.toContain('DataStore.save'); + expect(componentText).toContain('resetStateValues();'); + expect(componentText).toMatchSnapshot(); + expect(declaration).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/__tests__/studio-ui-codegen-react.test.ts b/packages/codegen-ui-react/lib/__tests__/studio-ui-codegen-react.test.ts index e39a00ab1..32279d618 100644 --- a/packages/codegen-ui-react/lib/__tests__/studio-ui-codegen-react.test.ts +++ b/packages/codegen-ui-react/lib/__tests__/studio-ui-codegen-react.test.ts @@ -13,6 +13,7 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { NoApiError } from '@aws-amplify/codegen-ui'; import { ModuleKind, ScriptTarget, ScriptKind } from '..'; import { authorHasManySchema, @@ -20,6 +21,7 @@ import { generateWithAmplifyRenderer, rendererConfigWithGraphQL, userSchema, + rendererConfigWithNoApi, } from './__utils__'; describe('amplify render tests', () => { @@ -98,6 +100,19 @@ describe('amplify render tests', () => { }); }); + describe('renderer configurations with NoApi', () => { + it('should throw if component has data binding', () => { + expect(() => { + generateWithAmplifyRenderer('workflow/dataStoreCreateItem', rendererConfigWithNoApi); + }).toThrow(NoApiError); + }); + + it('should render component without data binding successfully', () => { + const generatedCode = generateWithAmplifyRenderer('buttonGolden', rendererConfigWithNoApi); + expect(generatedCode.componentText).toMatchSnapshot(); + }); + }); + describe('collection', () => { it('should render collection with data binding', () => { const generatedCode = generateWithAmplifyRenderer('collectionWithBinding'); 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 9f9458129..b01905af7 100644 --- a/packages/codegen-ui-react/lib/forms/react-form-renderer.ts +++ b/packages/codegen-ui-react/lib/forms/react-form-renderer.ts @@ -29,6 +29,8 @@ import { StudioTemplateRenderer, validateFormSchema, FormFeatureFlags, + NoApiError, + formRequiresDataApi, } from '@aws-amplify/codegen-ui'; import { EOL } from 'os'; import { @@ -170,6 +172,11 @@ export abstract class ReactFormTemplateRenderer extends StudioTemplateRenderer< this.primaryKeys = dataSchemaMetadata.models[dataTypeName].primaryKeys; } } + + // validate inputs for renderer + if (formRequiresDataApi(component) && renderConfig.apiConfiguration?.dataApi === 'NoApi') { + throw new NoApiError('Form cannot be rendered without a data API'); + } } @handleCodegenErrors diff --git a/packages/codegen-ui-react/lib/react-render-config.ts b/packages/codegen-ui-react/lib/react-render-config.ts index d887027a3..6141976e7 100644 --- a/packages/codegen-ui-react/lib/react-render-config.ts +++ b/packages/codegen-ui-react/lib/react-render-config.ts @@ -18,7 +18,7 @@ import { ScriptKind, ScriptTarget, ModuleKind } from 'typescript'; export { ScriptKind, ScriptTarget, ModuleKind } from 'typescript'; -export type DataApiKind = 'DataStore' | 'GraphQL'; +export type DataApiKind = 'DataStore' | 'GraphQL' | 'NoApi'; export type ReactRenderConfig = FrameworkRenderConfig & { script?: ScriptKind; @@ -26,7 +26,7 @@ export type ReactRenderConfig = FrameworkRenderConfig & { module?: ModuleKind; renderTypeDeclarations?: boolean; inlineSourceMap?: boolean; - apiConfiguration?: GraphqlRenderConfig | DataStoreRenderConfig; + apiConfiguration?: GraphqlRenderConfig | DataStoreRenderConfig | NoApiRenderConfig; }; export type GraphqlRenderConfig = { @@ -42,6 +42,10 @@ export type DataStoreRenderConfig = { dataApi: 'DataStore'; }; +export type NoApiRenderConfig = { + dataApi: 'NoApi'; +}; + export function scriptKindToFileExtension(scriptKind: ScriptKind): string { switch (scriptKind) { case ScriptKind.TSX: diff --git a/packages/codegen-ui-react/lib/react-studio-template-renderer.ts b/packages/codegen-ui-react/lib/react-studio-template-renderer.ts index bffc4a609..94dfab421 100644 --- a/packages/codegen-ui-react/lib/react-studio-template-renderer.ts +++ b/packages/codegen-ui-react/lib/react-studio-template-renderer.ts @@ -38,6 +38,8 @@ import { isValidVariableName, InternalError, resolveBetweenPredicateToMultiplePredicates, + NoApiError, + componentRequiresDataApi, } from '@aws-amplify/codegen-ui'; import { EOL } from 'os'; import ts, { @@ -153,7 +155,11 @@ export abstract class ReactStudioTemplateRenderer extends StudioTemplateRenderer this.importCollection = new ImportCollection({ rendererConfig: renderConfig }); this.importCollection.ingestComponentMetadata(this.componentMetadata); addBindingPropertiesImports(this.component, this.importCollection); + // TODO: throw warnings on invalid config combinations. i.e. CommonJS + JSX + if (componentRequiresDataApi(component) && renderConfig.apiConfiguration?.dataApi === 'NoApi') { + throw new NoApiError('Component cannot be rendered without a data API'); + } } @handleCodegenErrors diff --git a/packages/codegen-ui-react/lib/utils/graphql.ts b/packages/codegen-ui-react/lib/utils/graphql.ts index 2e5ca69fb..fd2c187ce 100644 --- a/packages/codegen-ui-react/lib/utils/graphql.ts +++ b/packages/codegen-ui-react/lib/utils/graphql.ts @@ -29,7 +29,7 @@ import { ImportCollection, ImportValue } from '../imports'; import { capitalizeFirstLetter, getSetNameIdentifier, lowerCaseFirst } from '../helpers'; import { isBoundProperty, isConcatenatedProperty } from '../react-component-render-helper'; import { Primitive } from '../primitive'; -import { DataStoreRenderConfig, GraphqlRenderConfig } from '../react-render-config'; +import { DataStoreRenderConfig, GraphqlRenderConfig, NoApiRenderConfig } from '../react-render-config'; export enum ActionType { CREATE = 'create', @@ -42,7 +42,7 @@ export enum ActionType { /* istanbul ignore next */ export const isGraphqlConfig = ( - apiConfiguration?: GraphqlRenderConfig | DataStoreRenderConfig, + apiConfiguration?: GraphqlRenderConfig | DataStoreRenderConfig | NoApiRenderConfig, ): apiConfiguration is GraphqlRenderConfig => { return apiConfiguration?.dataApi === 'GraphQL'; }; diff --git a/packages/codegen-ui/lib/errors/error-transformer.ts b/packages/codegen-ui/lib/errors/error-transformer.ts index 76ad597d5..a403ca0ca 100644 --- a/packages/codegen-ui/lib/errors/error-transformer.ts +++ b/packages/codegen-ui/lib/errors/error-transformer.ts @@ -13,10 +13,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { InternalError, InvalidInputError } from './error-types'; +import { InternalError, InvalidInputError, NoApiError } from './error-types'; export const transformCodegenError = (error: any | unknown): InternalError | InvalidInputError => { - if (error instanceof InternalError || error instanceof InvalidInputError) { + if (error instanceof InternalError || error instanceof InvalidInputError || error instanceof NoApiError) { return error; } diff --git a/packages/codegen-ui/lib/errors/error-types.ts b/packages/codegen-ui/lib/errors/error-types.ts index b3ba64075..6ef1263e0 100644 --- a/packages/codegen-ui/lib/errors/error-types.ts +++ b/packages/codegen-ui/lib/errors/error-types.ts @@ -36,3 +36,13 @@ export class InvalidInputError extends Error { Object.setPrototypeOf(this, InvalidInputError.prototype); } } + +/** + * Entity requires a working data API to produce a working component but no valid API configuration was provided. + */ +export class NoApiError extends Error { + constructor(message: string) { + super(message); + Object.setPrototypeOf(this, NoApiError.prototype); + } +}