diff --git a/packages/codegen-ui-golden-files/lib/react/forms/datastore-update-form-with-has-many-relationship/SchoolUpdateForm.jsx b/packages/codegen-ui-golden-files/lib/react/forms/datastore-update-form-with-has-many-relationship/SchoolUpdateForm.jsx index 73ca2ca77..39f476c9b 100644 --- a/packages/codegen-ui-golden-files/lib/react/forms/datastore-update-form-with-has-many-relationship/SchoolUpdateForm.jsx +++ b/packages/codegen-ui-golden-files/lib/react/forms/datastore-update-form-with-has-many-relationship/SchoolUpdateForm.jsx @@ -164,6 +164,7 @@ export default function SchoolUpdateForm(props) { const [schoolRecord, setSchoolRecord] = React.useState(school); const [linkedStudents, setLinkedStudents] = React.useState([]); + const canUnlinkStudents = false; React.useEffect(() => { const queryData = async () => { @@ -252,6 +253,9 @@ export default function SchoolUpdateForm(props) { const studentsToUnLink = []; const studentsSet = new Set(); const linkedStudentsSet = new Set(); + if (!canUnlinkStudents && studentsToUnLink.length > 0) { + throw Error(`${original.id} cannot be unlinked from School because schoolID is a required field.`); + } Students.forEach((r) => studentsSet.add(r.id)); linkedStudents.forEach((r) => linkedStudentsSet.add(r.id)); @@ -269,6 +273,11 @@ export default function SchoolUpdateForm(props) { const promises = []; studentsToUnLink.forEach((original) => { + if (!canUnlinkStudents) { + throw Error( + `Student ${original.id} cannot be unlinked from School because schoolID is a required field.`, + ); + } promises.push( DataStore.save( Student.copyOf(original, (updated) => { 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 850c745b3..1723fa7af 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 @@ -4078,7 +4078,9 @@ export default function UpdateCompositeDogForm(props) { const [compositeDogRecord, setCompositeDogRecord] = React.useState(compositeDog); const [linkedCompositeToys, setLinkedCompositeToys] = React.useState([]); + const canUnlinkCompositeToys = true; const [linkedCompositeVets, setLinkedCompositeVets] = React.useState([]); + const canUnlinkCompositeVets = false; React.useEffect(() => { const queryData = async () => { const record = nameProp @@ -4329,27 +4331,19 @@ export default function UpdateCompositeDogForm(props) { } }); compositeToysToUnLink.forEach((original) => { - try { - promises.push( - DataStore.save( - CompositeToy.copyOf(original, (updated) => { - updated.compositeDogCompositeToysName = null; - updated.compositeDogCompositeToysDescription = null; - }) - ) + if (!canUnlinkCompositeToys) { + throw Error( + \`CompositeToy \${original.kind} cannot be unlinked from CompositeDog because compositeDogCompositeToysName is a required field.\` ); - } catch (err) { - if ( - err.message === - \\"Field compositeDogCompositeToysName is required\\" - ) { - throw Error( - \`\${original.id} cannot be unlinked from CompositeDog because compositeDogCompositeToysName is a required field.\` - ); - } else { - throw err; - } } + promises.push( + DataStore.save( + CompositeToy.copyOf(original, (updated) => { + updated.compositeDogCompositeToysName = null; + updated.compositeDogCompositeToysDescription = null; + }) + ) + ); }); compositeToysToLink.forEach((original) => { promises.push( @@ -5164,7 +5158,9 @@ export default function UpdateCPKTeacherForm(props) { }; const [cPKTeacherRecord, setCPKTeacherRecord] = React.useState(cPKTeacher); const [linkedCPKClasses, setLinkedCPKClasses] = React.useState([]); + const canUnlinkCPKClasses = false; const [linkedCPKProjects, setLinkedCPKProjects] = React.useState([]); + const canUnlinkCPKProjects = true; React.useEffect(() => { const queryData = async () => { const record = specialTeacherIdProp @@ -5414,23 +5410,18 @@ export default function UpdateCPKTeacherForm(props) { } }); cPKProjectsToUnLink.forEach((original) => { - try { - promises.push( - DataStore.save( - CPKProject.copyOf(original, (updated) => { - updated.cPKTeacherID = null; - }) - ) + if (!canUnlinkCPKProjects) { + throw Error( + \`CPKProject \${original.specialProjectId} cannot be unlinked from CPKTeacher because cPKTeacherID is a required field.\` ); - } catch (err) { - if (err.message === \\"Field cPKTeacherID is required\\") { - throw Error( - \`\${original.id} cannot be unlinked from CPKTeacher because cPKTeacherID is a required field.\` - ); - } else { - throw err; - } } + promises.push( + DataStore.save( + CPKProject.copyOf(original, (updated) => { + updated.cPKTeacherID = null; + }) + ) + ); }); cPKProjectsToLink.forEach((original) => { promises.push( @@ -11992,6 +11983,7 @@ export default function SchoolUpdateForm(props) { }; const [schoolRecord, setSchoolRecord] = React.useState(school); const [linkedStudents, setLinkedStudents] = React.useState([]); + const canUnlinkStudents = false; React.useEffect(() => { const queryData = async () => { const record = idProp ? await DataStore.query(School, idProp) : school; @@ -12110,23 +12102,18 @@ export default function SchoolUpdateForm(props) { } }); studentsToUnLink.forEach((original) => { - try { - promises.push( - DataStore.save( - Student.copyOf(original, (updated) => { - updated.schoolID = null; - }) - ) + if (!canUnlinkStudents) { + throw Error( + \`Student \${original.id} cannot be unlinked from School because schoolID is a required field.\` ); - } catch (err) { - if (err.message === \\"Field schoolID is required\\") { - throw Error( - \`\${original.id} cannot be unlinked from School because schoolID is a required field.\` - ); - } else { - throw err; - } } + promises.push( + DataStore.save( + Student.copyOf(original, (updated) => { + updated.schoolID = null; + }) + ) + ); }); studentsToLink.forEach((original) => { promises.push( @@ -13104,6 +13091,7 @@ export default function TagUpdateForm(props) { }; const [tagRecord, setTagRecord] = React.useState(tag); const [linkedPosts, setLinkedPosts] = React.useState([]); + const canUnlinkPosts = false; React.useEffect(() => { const queryData = async () => { const record = idProp ? await DataStore.query(Tag, idProp) : tag; 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 795b9b19d..594de8042 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 @@ -464,6 +464,17 @@ describe('amplify form renderer tests', () => { expect(declaration).toMatchSnapshot(); }); + it('should render an update form with validation for misconfigured schema for hasMany relationship', () => { + const { componentText } = generateWithAmplifyFormRenderer( + 'forms/school-datastore-update', + 'datastore/school-student', + undefined, + { isNonModelSupported: true, isRelationshipSupported: true }, + ); + + expect(componentText).toContain('const canUnlinkStudents = false'); + }); + it('should render an update form for model with composite keys', () => { const { componentText, declaration } = generateWithAmplifyFormRenderer( 'forms/composite-dog-datastore-update', 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 fa3f798dd..e2f32a374 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 @@ -23,6 +23,7 @@ import { VariableStatement, ExpressionStatement, PropertyAssignment, + IfStatement, } from 'typescript'; import { getSetNameIdentifier, lowerCaseFirst } from '../../helpers'; import { getDisplayValueObjectName } from './model-values'; @@ -205,7 +206,7 @@ export const buildDataStoreExpression = ( ) => { const thisModelPrimaryKeys = dataSchema.models[modelName].primaryKeys; // promises.push(...statements that handle hasMany/ manyToMany/ hasOne-belongsTo relationships) - const relationshipsPromisesAccessStatements: (VariableStatement | ExpressionStatement)[] = []; + const relationshipsPromisesAccessStatements: (VariableStatement | ExpressionStatement | IfStatement)[] = []; const hasManyRelationshipFields: string[] = []; const nonModelArrayFields: string[] = []; const savedRecordName = lowerCaseFirst(modelName); diff --git a/packages/codegen-ui-react/lib/forms/form-renderer-helper/form-state.ts b/packages/codegen-ui-react/lib/forms/form-renderer-helper/form-state.ts index 35cab0c6e..3a0cc0fc3 100644 --- a/packages/codegen-ui-react/lib/forms/form-renderer-helper/form-state.ts +++ b/packages/codegen-ui-react/lib/forms/form-renderer-helper/form-state.ts @@ -75,6 +75,8 @@ export const getRecordName = (modelName: string) => `${lowerCaseFirst(modelName) export const getLinkedDataName = (modelName: string) => `linked${capitalizeFirstLetter(modelName)}`; +export const getCanUnlinkModelName = (modelName: string) => `canUnlink${capitalizeFirstLetter(modelName)}`; + export const getCurrentValueIdentifier = (fieldName: string) => factory.createIdentifier(getCurrentValueName(fieldName)); 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 c6b2b8a73..24f6375f7 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 @@ -21,7 +21,7 @@ import { GenericDataModel, GenericDataField, } from '@aws-amplify/codegen-ui'; -import { getRecordsName, getLinkedDataName, buildAccessChain } from './form-state'; +import { getRecordsName, getLinkedDataName, buildAccessChain, getCanUnlinkModelName } from './form-state'; import { buildBaseCollectionVariableStatement } from '../../react-studio-template-renderer-helper'; import { ImportCollection, ImportSource } from '../../imports'; import { lowerCaseFirst, getSetNameIdentifier } from '../../helpers'; @@ -1553,120 +1553,95 @@ export const buildHasManyRelationshipDataStoreStatements = ( factory.createToken(SyntaxKind.EqualsGreaterThanToken), factory.createBlock( [ - factory.createTryStatement( + factory.createIfStatement( + factory.createPrefixUnaryExpression( + SyntaxKind.ExclamationToken, + factory.createIdentifier(getCanUnlinkModelName(fieldName)), + ), factory.createBlock( [ - factory.createExpressionStatement( - factory.createCallExpression( - factory.createPropertyAccessExpression( - factory.createIdentifier('promises'), - factory.createIdentifier('push'), - ), - undefined, - [ - factory.createCallExpression( + factory.createThrowStatement( + factory.createCallExpression(factory.createIdentifier('Error'), undefined, [ + factory.createTemplateExpression(factory.createTemplateHead(`${relatedModelName} `), [ + factory.createTemplateSpan( factory.createPropertyAccessExpression( - factory.createIdentifier('DataStore'), - factory.createIdentifier('save'), + factory.createIdentifier('original'), + factory.createIdentifier(keys[0]), + ), + factory.createTemplateTail( + // eslint-disable-next-line max-len + ` cannot be unlinked from ${modelName} because ${relatedModelFields[0]} is a required field.`, ), - undefined, - [ - factory.createCallExpression( - factory.createPropertyAccessExpression( - factory.createIdentifier(relatedModelName), - factory.createIdentifier('copyOf'), - ), - undefined, - [ - factory.createIdentifier('original'), - factory.createArrowFunction( - undefined, - undefined, - [ - factory.createParameterDeclaration( - undefined, - undefined, - undefined, - factory.createIdentifier('updated'), - undefined, - undefined, - undefined, - ), - ], - undefined, - factory.createToken(SyntaxKind.EqualsGreaterThanToken), - factory.createBlock( - relatedModelFields.map((relatedModelField) => - factory.createExpressionStatement( - factory.createBinaryExpression( - factory.createPropertyAccessExpression( - factory.createIdentifier('updated'), - factory.createIdentifier(relatedModelField), - ), - factory.createToken(SyntaxKind.EqualsToken), - factory.createNull(), - ), - ), - ), - true, - ), - ), - ], - ), - ], ), - ], - ), + ]), + ]), ), ], true, ), - factory.createCatchClause( - factory.createVariableDeclaration( - factory.createIdentifier('err'), - undefined, - undefined, - undefined, + undefined, + ), + factory.createExpressionStatement( + factory.createCallExpression( + factory.createPropertyAccessExpression( + factory.createIdentifier('promises'), + factory.createIdentifier('push'), ), - factory.createBlock( - [ - factory.createIfStatement( - factory.createBinaryExpression( + undefined, + [ + factory.createCallExpression( + factory.createPropertyAccessExpression( + factory.createIdentifier('DataStore'), + factory.createIdentifier('save'), + ), + undefined, + [ + factory.createCallExpression( factory.createPropertyAccessExpression( - factory.createIdentifier('err'), - factory.createIdentifier('message'), + factory.createIdentifier(relatedModelName), + factory.createIdentifier('copyOf'), ), - factory.createToken(SyntaxKind.EqualsEqualsEqualsToken), - factory.createStringLiteral(`Field ${relatedModelFields[0]} is required`), - ), - factory.createBlock( + undefined, [ - factory.createThrowStatement( - factory.createCallExpression(factory.createIdentifier('Error'), undefined, [ - factory.createTemplateExpression(factory.createTemplateHead('', ''), [ - factory.createTemplateSpan( - factory.createPropertyAccessExpression( - factory.createIdentifier('original'), - factory.createIdentifier('id'), - ), - factory.createTemplateTail( - // eslint-disable-next-line max-len - ` cannot be unlinked from ${modelName} because ${relatedModelFields[0]} is a required field.`, + factory.createIdentifier('original'), + factory.createArrowFunction( + undefined, + undefined, + [ + factory.createParameterDeclaration( + undefined, + undefined, + undefined, + factory.createIdentifier('updated'), + undefined, + undefined, + undefined, + ), + ], + undefined, + factory.createToken(SyntaxKind.EqualsGreaterThanToken), + factory.createBlock( + relatedModelFields.map((relatedModelField) => + factory.createExpressionStatement( + factory.createBinaryExpression( + factory.createPropertyAccessExpression( + factory.createIdentifier('updated'), + factory.createIdentifier(relatedModelField), + ), + factory.createToken(SyntaxKind.EqualsToken), + factory.createNull(), ), ), - ]), - ]), + ), + true, + ), ), ], - true, ), - factory.createBlock([factory.createThrowStatement(factory.createIdentifier('err'))], true), - ), - ], - true, - ), + ], + ), + ], ), - undefined, ), ], true, 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 7ceda070a..6bc0b479a 100644 --- a/packages/codegen-ui-react/lib/forms/react-form-renderer.ts +++ b/packages/codegen-ui-react/lib/forms/react-form-renderer.ts @@ -47,7 +47,7 @@ import { SyntaxKind, TypeAliasDeclaration, } from 'typescript'; -import { buildUseStateExpression, lowerCaseFirst } from '../helpers'; +import { buildInitConstVariableExpression, buildUseStateExpression, lowerCaseFirst } from '../helpers'; import { ImportCollection, ImportSource, ImportValue } from '../imports'; import { PrimitiveTypeParameter, Primitive, primitiveOverrideProp } from '../primitive'; import { getComponentPropName } from '../react-component-render-helper'; @@ -85,6 +85,7 @@ import { getInitialValues, getUseStateHooks, resetStateFunction, + getCanUnlinkModelName, } from './form-renderer-helper/form-state'; import { isModelDataType, shouldWrapInArrayField } from './form-renderer-helper/render-checkers'; import { @@ -483,6 +484,12 @@ export abstract class ReactFormTemplateRenderer extends StudioTemplateRenderer< const linkedDataName = getLinkedDataName(fieldName); linkedDataNames.push(linkedDataName); statements.push(buildUseStateExpression(linkedDataName, factory.createIdentifier('[]'))); + statements.push( + buildInitConstVariableExpression( + getCanUnlinkModelName(fieldName), + value.relationship.canUnlinkAssociatedModel ? factory.createTrue() : factory.createFalse(), + ), + ); } if (value.relationship.type === 'BELONGS_TO' || value.relationship?.type === 'HAS_ONE') { linkedDataNames.push(fieldName); diff --git a/packages/codegen-ui-react/lib/helpers/index.ts b/packages/codegen-ui-react/lib/helpers/index.ts index 5a9266f3f..648ea4e69 100644 --- a/packages/codegen-ui-react/lib/helpers/index.ts +++ b/packages/codegen-ui-react/lib/helpers/index.ts @@ -75,6 +75,24 @@ export const buildUseStateExpression = (name: string, defaultValue: Expression): ); }; +/** + * Create statement to declare and initialized a const. + * + * const name = value; + * @param name + * @param value + * @returns + */ +export const buildInitConstVariableExpression = (name: string, value: Expression): Statement => { + return factory.createVariableStatement( + undefined, + factory.createVariableDeclarationList( + [factory.createVariableDeclaration(factory.createIdentifier(name), undefined, undefined, value)], + NodeFlags.Const, + ), + ); +}; + export function fieldNeedsRelationshipLoadedForCollection(field: GenericDataField, dataSchema: GenericDataSchema) { const { relationship, dataType } = field; if (!relationship || !dataType) { diff --git a/packages/codegen-ui/lib/__tests__/generic-from-datastore.test.ts b/packages/codegen-ui/lib/__tests__/generic-from-datastore.test.ts index f9139c28b..debebb6fe 100644 --- a/packages/codegen-ui/lib/__tests__/generic-from-datastore.test.ts +++ b/packages/codegen-ui/lib/__tests__/generic-from-datastore.test.ts @@ -74,6 +74,7 @@ describe('getGenericFromDataStore', () => { type: 'HAS_MANY', relatedModelName: 'Teacher', relatedModelFields: ['student'], + canUnlinkAssociatedModel: false, relatedJoinFieldName: 'teacher', relatedJoinTableName: 'StudentTeacher', }); @@ -82,6 +83,7 @@ describe('getGenericFromDataStore', () => { type: 'HAS_MANY', relatedModelName: 'Student', relatedModelFields: ['teacher'], + canUnlinkAssociatedModel: false, relatedJoinFieldName: 'student', relatedJoinTableName: 'StudentTeacher', }); @@ -107,6 +109,7 @@ describe('getGenericFromDataStore', () => { type: 'HAS_MANY', relatedModelName: 'Dog', relatedModelFields: ['ownerID'], + canUnlinkAssociatedModel: true, relatedJoinFieldName: undefined, relatedJoinTableName: undefined, }); @@ -136,6 +139,7 @@ describe('getGenericFromDataStore', () => { type: 'HAS_MANY', relatedModelName: 'Teacher', relatedModelFields: ['student'], + canUnlinkAssociatedModel: false, relatedJoinFieldName: 'teacher', relatedJoinTableName: 'StudentTeacher', }); @@ -144,6 +148,7 @@ describe('getGenericFromDataStore', () => { type: 'HAS_MANY', relatedModelName: 'Student', relatedModelFields: ['teacher'], + canUnlinkAssociatedModel: false, relatedJoinFieldName: 'student', relatedJoinTableName: 'StudentTeacher', }); @@ -169,6 +174,7 @@ describe('getGenericFromDataStore', () => { type: 'HAS_MANY', relatedModelName: 'Dog', relatedModelFields: ['ownerID'], + canUnlinkAssociatedModel: true, relatedJoinFieldName: undefined, relatedJoinTableName: undefined, }); @@ -203,18 +209,20 @@ describe('getGenericFromDataStore', () => { const genericSchema = getGenericFromDataStore(schemaWithAssumptions); const userFields = genericSchema.models.User.fields; - expect(userFields.friends.relationship).toStrictEqual({ + expect(userFields.friends.relationship).toStrictEqual({ type: 'HAS_MANY', relatedModelName: 'Friend', relatedModelFields: ['friendId'], + canUnlinkAssociatedModel: true, relatedJoinFieldName: undefined, relatedJoinTableName: undefined, }); - expect(userFields.posts.relationship).toStrictEqual({ + expect(userFields.posts.relationship).toStrictEqual({ type: 'HAS_MANY', relatedModelName: 'Post', relatedModelFields: ['userPostsId'], + canUnlinkAssociatedModel: true, relatedJoinFieldName: undefined, relatedJoinTableName: undefined, }); diff --git a/packages/codegen-ui/lib/generic-from-datastore.ts b/packages/codegen-ui/lib/generic-from-datastore.ts index 6f4ef3856..790db2b9c 100644 --- a/packages/codegen-ui/lib/generic-from-datastore.ts +++ b/packages/codegen-ui/lib/generic-from-datastore.ts @@ -100,9 +100,14 @@ export function getGenericFromDataStore(dataStoreSchema: DataStoreSchema): Gener const associatedFieldNames = Array.isArray(field.association?.associatedWith) ? field.association.associatedWith : [field.association.associatedWith]; + let canUnlinkAssociatedModel = true; associatedFieldNames.forEach((associatedFieldName) => { const associatedField = associatedModel?.fields[associatedFieldName]; + // if any of the associatedField is required, you cannot unlink from parent model + if (associatedField?.isRequired) { + canUnlinkAssociatedModel = false; + } // if the associated model is a join table, update relatedModelName to the actual related model if ( associatedField && @@ -134,6 +139,7 @@ export function getGenericFromDataStore(dataStoreSchema: DataStoreSchema): Gener }); modelRelationship = { type: relationshipType, + canUnlinkAssociatedModel, relatedModelName, relatedModelFields: associatedFieldNames, relatedJoinFieldName, diff --git a/packages/codegen-ui/lib/types/data.ts b/packages/codegen-ui/lib/types/data.ts index 5b0ed0564..c5acd8795 100644 --- a/packages/codegen-ui/lib/types/data.ts +++ b/packages/codegen-ui/lib/types/data.ts @@ -37,6 +37,7 @@ export type CommonRelationshipType = { export type HasManyRelationshipType = { type: 'HAS_MANY'; relatedModelFields: string[]; + canUnlinkAssociatedModel: boolean; relatedJoinFieldName?: string; relatedJoinTableName?: string; } & CommonRelationshipType;