From 3df04d4c736715f0a254136a88a2b2150d9ade43 Mon Sep 17 00:00:00 2001 From: Justin Shih Date: Mon, 5 Jun 2023 11:08:55 -0700 Subject: [PATCH] fix: alias colliding imported model name in hasMany relationship --- ...studio-ui-codegen-react-forms.test.ts.snap | 613 ++++++++++++++++++ .../studio-ui-codegen-react-forms.test.ts | 29 + .../forms/form-renderer-helper/cta-props.ts | 1 + .../form-renderer-helper/relationship.ts | 6 +- .../datastore/school-student-collision.json | 155 +++++ 5 files changed, 802 insertions(+), 2 deletions(-) create mode 100644 packages/codegen-ui/example-schemas/datastore/school-student-collision.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 c9eecf0b4..fd94a1fbe 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 @@ -18559,6 +18559,619 @@ export default function SchoolUpdateForm(props: SchoolUpdateFormProps): React.Re " `; +exports[`amplify form renderer tests datastore form tests should generate a update form with hasMany relationship with model name collision 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, + useDataStoreBinding, +} from \\"@aws-amplify/ui-react/internal\\"; +import { School, Student as Student0 } from \\"../models\\"; +import { fetchByPath, validateField } from \\"./utils\\"; +import { DataStore } from \\"aws-amplify\\"; +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 SchoolUpdateForm(props) { + const { + id: idProp, + school: schoolModelProp, + onSuccess, + onError, + onSubmit, + onCancel, + onValidate, + onChange, + overrides, + ...rest + } = props; + const initialValues = { + name: \\"\\", + Student: [], + Students: undefined, + }; + const [name, setName] = React.useState(initialValues.name); + const [Student, setStudent] = React.useState(initialValues.Student); + const [Students, setStudents] = React.useState(initialValues.Students); + const [errors, setErrors] = React.useState({}); + const resetStateValues = () => { + const cleanValues = schoolRecord + ? { ...initialValues, ...schoolRecord, Student: linkedStudent } + : initialValues; + setName(cleanValues.name); + setStudent(cleanValues.Student ?? []); + setCurrentStudentValue(undefined); + setCurrentStudentDisplayValue(\\"\\"); + setStudents(cleanValues.Students); + setErrors({}); + }; + const [schoolRecord, setSchoolRecord] = React.useState(schoolModelProp); + const [linkedStudent, setLinkedStudent] = React.useState([]); + const canUnlinkStudent = false; + React.useEffect(() => { + const queryData = async () => { + const record = idProp + ? await DataStore.query(School, idProp) + : schoolModelProp; + setSchoolRecord(record); + const linkedStudent = record ? await record.Student.toArray() : []; + setLinkedStudent(linkedStudent); + }; + queryData(); + }, [idProp, schoolModelProp]); + React.useEffect(resetStateValues, [schoolRecord, linkedStudent]); + const [currentStudentDisplayValue, setCurrentStudentDisplayValue] = + React.useState(\\"\\"); + const [currentStudentValue, setCurrentStudentValue] = + React.useState(undefined); + const StudentRef = React.createRef(); + const getIDValue = { + Student: (r) => JSON.stringify({ id: r?.id }), + }; + const StudentIdSet = new Set( + Array.isArray(Student) + ? Student.map((r) => getIDValue.Student?.(r)) + : getIDValue.Student?.(Student) + ); + const studentRecords = useDataStoreBinding({ + type: \\"collection\\", + model: Student0, + }).items; + const getDisplayValue = { + Student: (r) => \`\${r?.name ? r?.name + \\" - \\" : \\"\\"}\${r?.id}\`, + }; + const validations = { + name: [], + Student: [], + Students: [], + }; + 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(); + let modelFields = { + name, + Student, + Students, + }; + 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, + getDisplayValue[fieldName] + ) + ) + ); + return promises; + } + promises.push( + runValidationTasks( + fieldName, + modelFields[fieldName], + getDisplayValue[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 promises = []; + const studentToLink = []; + const studentToUnLink = []; + const studentSet = new Set(); + const linkedStudentSet = new Set(); + Student.forEach((r) => studentSet.add(getIDValue.Student?.(r))); + linkedStudent.forEach((r) => + linkedStudentSet.add(getIDValue.Student?.(r)) + ); + linkedStudent.forEach((r) => { + if (!studentSet.has(getIDValue.Student?.(r))) { + studentToUnLink.push(r); + } + }); + Student.forEach((r) => { + if (!linkedStudentSet.has(getIDValue.Student?.(r))) { + studentToLink.push(r); + } + }); + studentToUnLink.forEach((original) => { + if (!canUnlinkStudent) { + throw Error( + \`Student \${original.id} cannot be unlinked from School because schoolID is a required field.\` + ); + } + promises.push( + DataStore.save( + Student0.copyOf(original, (updated) => { + updated.schoolID = null; + }) + ) + ); + }); + studentToLink.forEach((original) => { + promises.push( + DataStore.save( + Student0.copyOf(original, (updated) => { + updated.schoolID = schoolRecord.id; + }) + ) + ); + }); + const modelFieldsToSave = { + name: modelFields.name, + }; + promises.push( + DataStore.save( + School.copyOf(schoolRecord, (updated) => { + Object.assign(updated, modelFieldsToSave); + }) + ) + ); + await Promise.all(promises); + if (onSuccess) { + onSuccess(modelFields); + } + } catch (err) { + if (onError) { + onError(modelFields, err.message); + } + } + }} + {...getOverrideProps(overrides, \\"SchoolUpdateForm\\")} + {...rest} + > + { + let { value } = e.target; + if (onChange) { + const modelFields = { + name: value, + Student, + Students, + }; + 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, + Student: values, + Students, + }; + const result = onChange(modelFields); + values = result?.Student ?? values; + } + setStudent(values); + setCurrentStudentValue(undefined); + setCurrentStudentDisplayValue(\\"\\"); + }} + currentFieldValue={currentStudentValue} + label={\\"Student\\"} + items={Student} + hasError={errors?.Student?.hasError} + errorMessage={errors?.Student?.errorMessage} + getBadgeText={getDisplayValue.Student} + setFieldValue={(model) => { + setCurrentStudentDisplayValue( + model ? getDisplayValue.Student(model) : \\"\\" + ); + setCurrentStudentValue(model); + }} + inputFieldRef={StudentRef} + defaultFieldValue={\\"\\"} + > + !StudentIdSet.has(getIDValue.Student?.(r))) + .map((r) => ({ + id: getIDValue.Student?.(r), + label: getDisplayValue.Student?.(r), + }))} + onSelect={({ id, label }) => { + setCurrentStudentValue( + studentRecords.find((r) => + Object.entries(JSON.parse(id)).every( + ([key, value]) => r[key] === value + ) + ) + ); + setCurrentStudentDisplayValue(label); + runValidationTasks(\\"Student\\", label); + }} + onClear={() => { + setCurrentStudentDisplayValue(\\"\\"); + }} + onChange={(e) => { + let { value } = e.target; + if (errors.Student?.hasError) { + runValidationTasks(\\"Student\\", value); + } + setCurrentStudentDisplayValue(value); + setCurrentStudentValue(undefined); + }} + onBlur={() => + runValidationTasks(\\"Student\\", currentStudentDisplayValue) + } + errorMessage={errors.Student?.errorMessage} + hasError={errors.Student?.hasError} + ref={StudentRef} + labelHidden={true} + {...getOverrideProps(overrides, \\"Student\\")} + > + + arr.findIndex((member) => member?.id === r?.id) === i + ) + .map((r) => ({ + id: r?.id, + label: getDisplayValue.Students?.(r), + }))} + onSelect={({ id, label }) => { + setStudents(id); + runValidationTasks(\\"Students\\", id); + }} + onClear={() => { + setStudents(\\"\\"); + }} + defaultValue={Students} + onChange={(e) => { + let { value } = e.target; + if (onChange) { + const modelFields = { + name, + Student, + Students: value, + }; + const result = onChange(modelFields); + value = result?.Students ?? value; + } + if (errors.Students?.hasError) { + runValidationTasks(\\"Students\\", value); + } + setStudents(value); + }} + onBlur={() => runValidationTasks(\\"Students\\", Students)} + errorMessage={errors.Students?.errorMessage} + hasError={errors.Students?.hasError} + labelHidden={false} + {...getOverrideProps(overrides, \\"Students\\")} + > + + + + + + + + + ); +} +" +`; + +exports[`amplify form renderer tests datastore form tests should generate a update form with hasMany relationship with model name collision 2`] = ` +"import * as React from \\"react\\"; +import { AutocompleteProps, GridProps, TextFieldProps } from \\"@aws-amplify/ui-react\\"; +import { EscapeHatchProps } from \\"@aws-amplify/ui-react/internal\\"; +import { School, Student as Student0 } from \\"../models\\"; +export declare type ValidationResponse = { + hasError: boolean; + errorMessage?: string; +}; +export declare type ValidationFunction = (value: T, validationResponse: ValidationResponse) => ValidationResponse | Promise; +export declare type SchoolUpdateFormInputValues = { + name?: string; + Student?: Student0[]; + Students?: string; +}; +export declare type SchoolUpdateFormValidationValues = { + name?: ValidationFunction; + Student?: ValidationFunction; + Students?: ValidationFunction; +}; +export declare type PrimitiveOverrideProps = Partial & React.DOMAttributes; +export declare type SchoolUpdateFormOverridesProps = { + SchoolUpdateFormGrid?: PrimitiveOverrideProps; + name?: PrimitiveOverrideProps; + Student?: PrimitiveOverrideProps; + Students?: PrimitiveOverrideProps; +} & EscapeHatchProps; +export declare type SchoolUpdateFormProps = React.PropsWithChildren<{ + overrides?: SchoolUpdateFormOverridesProps | undefined | null; +} & { + id?: string; + school?: School; + onSubmit?: (fields: SchoolUpdateFormInputValues) => SchoolUpdateFormInputValues; + onSuccess?: (fields: SchoolUpdateFormInputValues) => void; + onError?: (fields: SchoolUpdateFormInputValues, errorMessage: string) => void; + onCancel?: () => void; + onChange?: (fields: SchoolUpdateFormInputValues) => SchoolUpdateFormInputValues; + onValidate?: SchoolUpdateFormValidationValues; +} & React.CSSProperties>; +export default function SchoolUpdateForm(props: SchoolUpdateFormProps): React.ReactElement; +" +`; + exports[`amplify form renderer tests datastore form tests should generate an update form with belongsTo 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 82fe46d5a..f790b4a01 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 @@ -235,6 +235,35 @@ describe('amplify form renderer tests', () => { expect(declaration).toMatchSnapshot(); }); + it('should generate a update form with hasMany relationship with model name collision', () => { + const { componentText, declaration } = generateWithAmplifyFormRenderer( + 'forms/school-datastore-update', + 'datastore/school-student-collision', + undefined, + { isNonModelSupported: true, isRelationshipSupported: true }, + ); + // check nested model is imported + expect(componentText).toContain('import { School, Student as Student0 } from "../models";'); + + // check binding call is generated + expect(componentText).toContain('const studentRecords = useDataStoreBinding({'); + + // check lazy load linked data + expect(componentText).toContain('const linkedStudent = record ? await record.Student.toArray() : [];'); + + // check custom display value is set + expect(componentText).toContain('Student: (r) => `${r?.name ? r?.name + " - " : ""}${r?.id}`,'); + + // check linked data useState is generate + expect(componentText).toContain('const [linkedStudent, setLinkedStudent] = React.useState([]);'); + + // check resetStateValues has correct dependencies + expect(componentText).toContain('React.useEffect(resetStateValues, [schoolRecord, linkedStudent]);'); + + expect(componentText).toMatchSnapshot(); + expect(declaration).toMatchSnapshot(); + }); + it('should render form with a two inputs in row', () => { const { componentText, declaration } = generateWithAmplifyFormRenderer( 'forms/post-datastore-create-row', diff --git a/packages/codegen-ui-react/lib/forms/form-renderer-helper/cta-props.ts b/packages/codegen-ui-react/lib/forms/form-renderer-helper/cta-props.ts index 9ffd7e17f..1a88d2572 100644 --- a/packages/codegen-ui-react/lib/forms/form-renderer-helper/cta-props.ts +++ b/packages/codegen-ui-react/lib/forms/form-renderer-helper/cta-props.ts @@ -261,6 +261,7 @@ export const buildDataStoreExpression = ( fieldConfig, thisModelPrimaryKeys, savedRecordName, + importCollection, ), ); } diff --git a/packages/codegen-ui-react/lib/forms/form-renderer-helper/relationship.ts b/packages/codegen-ui-react/lib/forms/form-renderer-helper/relationship.ts index 2b28f0845..4caaf3169 100644 --- a/packages/codegen-ui-react/lib/forms/form-renderer-helper/relationship.ts +++ b/packages/codegen-ui-react/lib/forms/form-renderer-helper/relationship.ts @@ -1275,12 +1275,14 @@ export const buildHasManyRelationshipDataStoreStatements = ( hasManyFieldConfig: [string, FieldConfigMetadata], thisModelPrimaryKeys: string[], savedModelName: string, + importCollection: ImportCollection, ) => { let [fieldName] = hasManyFieldConfig; const [, fieldConfigMetaData] = hasManyFieldConfig; fieldName = fieldConfigMetaData.sanitizedFieldName || fieldName; const { relatedModelName, relatedModelFields, belongsToFieldOnRelatedModel } = fieldConfigMetaData.relationship as HasManyRelationshipType; + const relatedModelVariableName = importCollection.getMappedAlias(ImportSource.LOCAL_MODELS, relatedModelName); const linkedDataName = getLinkedDataName(fieldName); const dataToLink = `${lowerCaseFirst(fieldName)}ToLink`; const dataToUnLink = `${lowerCaseFirst(fieldName)}ToUnLink`; @@ -1619,7 +1621,7 @@ export const buildHasManyRelationshipDataStoreStatements = ( [ factory.createCallExpression( factory.createPropertyAccessExpression( - factory.createIdentifier(relatedModelName), + factory.createIdentifier(relatedModelVariableName), factory.createIdentifier('copyOf'), ), undefined, @@ -1706,7 +1708,7 @@ export const buildHasManyRelationshipDataStoreStatements = ( [ factory.createCallExpression( factory.createPropertyAccessExpression( - factory.createIdentifier(relatedModelName), + factory.createIdentifier(relatedModelVariableName), factory.createIdentifier('copyOf'), ), undefined, diff --git a/packages/codegen-ui/example-schemas/datastore/school-student-collision.json b/packages/codegen-ui/example-schemas/datastore/school-student-collision.json new file mode 100644 index 000000000..d5c4c40e8 --- /dev/null +++ b/packages/codegen-ui/example-schemas/datastore/school-student-collision.json @@ -0,0 +1,155 @@ +{ + "models": { + "School": { + "name": "School", + "fields": { + "id": { + "name": "id", + "isArray": false, + "type": "ID", + "isRequired": true, + "attributes": [] + }, + "name": { + "name": "name", + "isArray": false, + "type": "String", + "isRequired": false, + "attributes": [] + }, + "Student": { + "name": "Student", + "isArray": true, + "type": { + "model": "Student" + }, + "isRequired": false, + "attributes": [], + "isArrayNullable": true, + "association": { + "connectionType": "HAS_MANY", + "associatedWith": "schoolID" + } + }, + "createdAt": { + "name": "createdAt", + "isArray": false, + "type": "AWSDateTime", + "isRequired": false, + "attributes": [], + "isReadOnly": true + }, + "updatedAt": { + "name": "updatedAt", + "isArray": false, + "type": "AWSDateTime", + "isRequired": false, + "attributes": [], + "isReadOnly": true + } + }, + "syncable": true, + "pluralName": "Schools", + "attributes": [ + { + "type": "model", + "properties": {} + }, + { + "type": "auth", + "properties": { + "rules": [ + { + "allow": "public", + "operations": [ + "create", + "update", + "delete", + "read" + ] + } + ] + } + } + ] + }, + "Student": { + "name": "Student", + "fields": { + "id": { + "name": "id", + "isArray": false, + "type": "ID", + "isRequired": true, + "attributes": [] + }, + "name": { + "name": "name", + "isArray": false, + "type": "String", + "isRequired": false, + "attributes": [] + }, + "schoolID": { + "name": "schoolID", + "isArray": false, + "type": "ID", + "isRequired": true, + "attributes": [] + }, + "createdAt": { + "name": "createdAt", + "isArray": false, + "type": "AWSDateTime", + "isRequired": false, + "attributes": [], + "isReadOnly": true + }, + "updatedAt": { + "name": "updatedAt", + "isArray": false, + "type": "AWSDateTime", + "isRequired": false, + "attributes": [], + "isReadOnly": true + } + }, + "syncable": true, + "pluralName": "Students", + "attributes": [ + { + "type": "model", + "properties": {} + }, + { + "type": "key", + "properties": { + "name": "bySchool", + "fields": [ + "schoolID" + ] + } + }, + { + "type": "auth", + "properties": { + "rules": [ + { + "allow": "public", + "operations": [ + "create", + "update", + "delete", + "read" + ] + } + ] + } + } + ] + } + }, + "enums": {}, + "nonModels": {}, + "version": "5e020d89e4dbb0a2e3b90b771dbcff66" +} \ No newline at end of file