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 b2292c44..d0577c68 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 @@ -5696,6 +5696,768 @@ export default function MyPostForm(props: MyPostFormProps): React.ReactElement; " `; +exports[`amplify form renderer tests GraphQL form tests should generate an update form with composite primary key 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\\"; +import { API } from \\"aws-amplify\\"; +import { getMovie, listMovieTags, listTags } from \\"../graphql/queries\\"; +import { + createMovieTags, + deleteMovieTags, + updateMovie, +} from \\"../graphql/mutations\\"; +function ArrayField({ + items = [], + onChange, + label, + inputFieldRef, + children, + hasError, + setFieldValue, + currentFieldValue, + defaultFieldValue, + lengthLimit, + getBadgeText, + errorMessage, +}) { + const labelElement = <Text>{label}</Text>; + 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 = ( + <React.Fragment> + {!!items?.length && ( + <ScrollView height=\\"inherit\\" width=\\"inherit\\" maxHeight={\\"7rem\\"}> + {items.map((value, index) => { + return ( + <Badge + key={index} + style={{ + cursor: \\"pointer\\", + alignItems: \\"center\\", + marginRight: 3, + marginTop: 3, + backgroundColor: + index === selectedBadgeIndex ? \\"#B8CEF9\\" : \\"\\", + }} + onClick={() => { + setSelectedBadgeIndex(index); + setFieldValue(items[index]); + setIsEditing(true); + }} + > + {getBadgeText ? getBadgeText(value) : value.toString()} + <Icon + style={{ + cursor: \\"pointer\\", + paddingLeft: 3, + width: 20, + height: 20, + }} + viewBox={{ width: 20, height: 20 }} + paths={[ + { + d: \\"M10 10l5.09-5.09L10 10l5.09 5.09L10 10zm0 0L4.91 4.91 10 10l-5.09 5.09L10 10z\\", + stroke: \\"black\\", + }, + ]} + ariaLabel=\\"button\\" + onClick={(event) => { + event.stopPropagation(); + removeItem(index); + }} + /> + </Badge> + ); + })} + </ScrollView> + )} + <Divider orientation=\\"horizontal\\" marginTop={5} /> + </React.Fragment> + ); + if (lengthLimit !== undefined && items.length >= lengthLimit && !isEditing) { + return ( + <React.Fragment> + {labelElement} + {arraySection} + </React.Fragment> + ); + } + return ( + <React.Fragment> + {labelElement} + {isEditing && children} + {!isEditing ? ( + <> + <Button + onClick={() => { + setIsEditing(true); + }} + > + Add item + </Button> + {errorMessage && hasError && ( + <Text color={errorStyles.color} fontSize={errorStyles.fontSize}> + {errorMessage} + </Text> + )} + </> + ) : ( + <Flex justifyContent=\\"flex-end\\"> + {(currentFieldValue || isEditing) && ( + <Button + children=\\"Cancel\\" + type=\\"button\\" + size=\\"small\\" + onClick={() => { + setFieldValue(defaultFieldValue); + setIsEditing(false); + setSelectedBadgeIndex(undefined); + }} + ></Button> + )} + <Button + size=\\"small\\" + variation=\\"link\\" + isDisabled={hasError} + onClick={addItem} + > + {selectedBadgeIndex !== undefined ? \\"Save\\" : \\"Add\\"} + </Button> + </Flex> + )} + {arraySection} + </React.Fragment> + ); +} +export default function MovieUpdateForm(props) { + const { + id: idProp, + movie: movieModelProp, + onSuccess, + onError, + onSubmit, + onValidate, + onChange, + overrides, + ...rest + } = props; + const initialValues = { + movieKey: \\"\\", + title: \\"\\", + genre: \\"\\", + rating: \\"\\", + tags: [], + }; + const [movieKey, setMovieKey] = React.useState(initialValues.movieKey); + const [title, setTitle] = React.useState(initialValues.title); + const [genre, setGenre] = React.useState(initialValues.genre); + const [rating, setRating] = React.useState(initialValues.rating); + const [tags, setTags] = React.useState(initialValues.tags); + const [tagsLoading, setTagsLoading] = React.useState(false); + const [tagsRecords, setTagsRecords] = React.useState([]); + const autocompleteLength = 10; + const [errors, setErrors] = React.useState({}); + const resetStateValues = () => { + const cleanValues = movieRecord + ? { ...initialValues, ...movieRecord, tags: linkedTags } + : initialValues; + setMovieKey(cleanValues.movieKey); + setTitle(cleanValues.title); + setGenre(cleanValues.genre); + setRating(cleanValues.rating); + setTags(cleanValues.tags ?? []); + setCurrentTagsValue(undefined); + setCurrentTagsDisplayValue(\\"\\"); + setErrors({}); + }; + const [movieRecord, setMovieRecord] = React.useState(movieModelProp); + const [linkedTags, setLinkedTags] = React.useState([]); + const canUnlinkTags = false; + React.useEffect(() => { + const queryData = async () => { + const record = idProp + ? ( + await API.graphql({ + query: getMovie, + variables: { ...idProp }, + }) + ).data.getMovie + : movieModelProp; + setMovieRecord(record); + const linkedTags = record + ? await Promise.all( + ( + await record.tags.toArray() + ).map((r) => { + return r.tag; + }) + ) + : []; + setLinkedTags(linkedTags); + }; + queryData(); + }, [idProp, movieModelProp]); + React.useEffect(resetStateValues, [movieRecord, linkedTags]); + const [currentTagsDisplayValue, setCurrentTagsDisplayValue] = + React.useState(\\"\\"); + const [currentTagsValue, setCurrentTagsValue] = React.useState(undefined); + const tagsRef = React.createRef(); + const getIDValue = { + tags: (r) => JSON.stringify({ id: r?.id }), + }; + const tagsIdSet = new Set( + Array.isArray(tags) + ? tags.map((r) => getIDValue.tags?.(r)) + : getIDValue.tags?.(tags) + ); + const getDisplayValue = { + tags: (r) => \`\${r?.label ? r?.label + \\" - \\" : \\"\\"}\${r?.id}\`, + }; + const validations = { + movieKey: [{ type: \\"Required\\" }], + title: [{ type: \\"Required\\" }], + genre: [{ type: \\"Required\\" }], + rating: [], + tags: [], + }; + 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; + }; + const fetchTagsRecords = async (value) => { + setTagsLoading(true); + const newOptions = []; + let newNext = \\"\\"; + while (newOptions.length < autocompleteLength && newNext != null) { + const variables = { + limit: autocompleteLength * 5, + filter: { + or: [{ label: { contains: value } }, { id: { contains: value } }], + }, + }; + if (newNext) { + variables[\\"nextToken\\"] = newNext; + } + const result = ( + await API.graphql({ + query: listTags, + variables, + }) + ).data.listTags.items; + var loaded = result.filter( + (item) => !tagsIdSet.has(getIDValue.tags?.(item)) + ); + newOptions.push(...loaded); + newNext = result.nextToken; + } + setTagsRecords(newOptions.slice(0, autocompleteLength)); + setTagsLoading(false); + }; + return ( + <Grid + as=\\"form\\" + rowGap=\\"15px\\" + columnGap=\\"15px\\" + padding=\\"20px\\" + onSubmit={async (event) => { + event.preventDefault(); + let modelFields = { + movieKey, + title, + genre, + rating, + tags, + }; + 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 tagsToLinkMap = new Map(); + const tagsToUnLinkMap = new Map(); + const tagsMap = new Map(); + const linkedTagsMap = new Map(); + tags.forEach((r) => { + const count = tagsMap.get(getIDValue.tags?.(r)); + const newCount = count ? count + 1 : 1; + tagsMap.set(getIDValue.tags?.(r), newCount); + }); + linkedTags.forEach((r) => { + const count = linkedTagsMap.get(getIDValue.tags?.(r)); + const newCount = count ? count + 1 : 1; + linkedTagsMap.set(getIDValue.tags?.(r), newCount); + }); + linkedTagsMap.forEach((count, id) => { + const newCount = tagsMap.get(id); + if (newCount) { + const diffCount = count - newCount; + if (diffCount > 0) { + tagsToUnLinkMap.set(id, diffCount); + } + } else { + tagsToUnLinkMap.set(id, count); + } + }); + tagsMap.forEach((count, id) => { + const originalCount = linkedTagsMap.get(id); + if (originalCount) { + const diffCount = count - originalCount; + if (diffCount > 0) { + tagsToLinkMap.set(id, diffCount); + } + } else { + tagsToLinkMap.set(id, count); + } + }); + tagsToUnLinkMap.forEach(async (count, id) => { + const recordKeys = JSON.parse(id); + const movieTagsRecords = ( + await API.graphql({ + query: listMovieTags, + variables: { + filter: { + and: [ + { tagId: { eq: recordKeys.id } }, + { movieMovieKey: { eq: movieRecord.movieKey } }, + { movietitle: { eq: movieRecord.title } }, + { moviegenre: { eq: movieRecord.genre } }, + ], + }, + }, + }) + ).data.listMovieTags.items; + for (let i = 0; i < count; i++) { + promises.push( + API.graphql({ + query: deleteMovieTags, + variables: { + input: { + id: movieTagsRecords[i].id, + }, + }, + }) + ); + } + }); + tagsToLinkMap.forEach((count, id) => { + for (let i = count; i > 0; i--) { + promises.push( + API.graphql({ + query: createMovieTags, + variables: { + input: { + movie: movieRecord, + tag: tagRecords.find((r) => + Object.entries(JSON.parse(id)).every( + ([key, value]) => r[key] === value + ) + ), + }, + }, + }) + ); + } + }); + const modelFieldsToSave = { + movieKey: modelFields.movieKey, + title: modelFields.title, + genre: modelFields.genre, + rating: modelFields.rating, + }; + promises.push( + API.graphql({ + query: updateMovie, + variables: { + input: { + movieKey: movieRecord.movieKey, + title: movieRecord.title, + genre: movieRecord.genre, + ...modelFieldsToSave, + }, + }, + }) + ); + await Promise.all(promises); + if (onSuccess) { + onSuccess(modelFields); + } + } catch (err) { + if (onError) { + onError(modelFields, err.message); + } + } + }} + {...getOverrideProps(overrides, \\"MovieUpdateForm\\")} + {...rest} + > + <TextField + label=\\"Movie key\\" + isRequired={true} + isReadOnly={true} + value={movieKey} + onChange={(e) => { + let { value } = e.target; + if (onChange) { + const modelFields = { + movieKey: value, + title, + genre, + rating, + tags, + }; + const result = onChange(modelFields); + value = result?.movieKey ?? value; + } + if (errors.movieKey?.hasError) { + runValidationTasks(\\"movieKey\\", value); + } + setMovieKey(value); + }} + onBlur={() => runValidationTasks(\\"movieKey\\", movieKey)} + errorMessage={errors.movieKey?.errorMessage} + hasError={errors.movieKey?.hasError} + {...getOverrideProps(overrides, \\"movieKey\\")} + ></TextField> + <TextField + label=\\"Title\\" + isRequired={true} + isReadOnly={true} + value={title} + onChange={(e) => { + let { value } = e.target; + if (onChange) { + const modelFields = { + movieKey, + title: value, + genre, + rating, + tags, + }; + const result = onChange(modelFields); + value = result?.title ?? value; + } + if (errors.title?.hasError) { + runValidationTasks(\\"title\\", value); + } + setTitle(value); + }} + onBlur={() => runValidationTasks(\\"title\\", title)} + errorMessage={errors.title?.errorMessage} + hasError={errors.title?.hasError} + {...getOverrideProps(overrides, \\"title\\")} + ></TextField> + <TextField + label=\\"Genre\\" + isRequired={true} + isReadOnly={true} + value={genre} + onChange={(e) => { + let { value } = e.target; + if (onChange) { + const modelFields = { + movieKey, + title, + genre: value, + rating, + tags, + }; + const result = onChange(modelFields); + value = result?.genre ?? value; + } + if (errors.genre?.hasError) { + runValidationTasks(\\"genre\\", value); + } + setGenre(value); + }} + onBlur={() => runValidationTasks(\\"genre\\", genre)} + errorMessage={errors.genre?.errorMessage} + hasError={errors.genre?.hasError} + {...getOverrideProps(overrides, \\"genre\\")} + ></TextField> + <TextField + label=\\"Rating\\" + isRequired={false} + isReadOnly={false} + value={rating} + onChange={(e) => { + let { value } = e.target; + if (onChange) { + const modelFields = { + movieKey, + title, + genre, + rating: value, + tags, + }; + const result = onChange(modelFields); + value = result?.rating ?? value; + } + if (errors.rating?.hasError) { + runValidationTasks(\\"rating\\", value); + } + setRating(value); + }} + onBlur={() => runValidationTasks(\\"rating\\", rating)} + errorMessage={errors.rating?.errorMessage} + hasError={errors.rating?.hasError} + {...getOverrideProps(overrides, \\"rating\\")} + ></TextField> + <ArrayField + onChange={async (items) => { + let values = items; + if (onChange) { + const modelFields = { + movieKey, + title, + genre, + rating, + tags: values, + }; + const result = onChange(modelFields); + values = result?.tags ?? values; + } + setTags(values); + setCurrentTagsValue(undefined); + setCurrentTagsDisplayValue(\\"\\"); + }} + currentFieldValue={currentTagsValue} + label={\\"Tags\\"} + items={tags} + hasError={errors?.tags?.hasError} + errorMessage={errors?.tags?.errorMessage} + getBadgeText={getDisplayValue.tags} + setFieldValue={(model) => { + setCurrentTagsDisplayValue(model ? getDisplayValue.tags(model) : \\"\\"); + setCurrentTagsValue(model); + }} + inputFieldRef={tagsRef} + defaultFieldValue={\\"\\"} + > + <Autocomplete + label=\\"Tags\\" + isRequired={false} + isReadOnly={false} + placeholder=\\"Search Tag\\" + value={currentTagsDisplayValue} + options={tagsRecords.map((r) => ({ + id: getIDValue.tags?.(r), + label: getDisplayValue.tags?.(r), + }))} + isLoading={tagsLoading} + onSelect={({ id, label }) => { + setCurrentTagsValue( + tagRecords.find((r) => + Object.entries(JSON.parse(id)).every( + ([key, value]) => r[key] === value + ) + ) + ); + setCurrentTagsDisplayValue(label); + runValidationTasks(\\"tags\\", label); + }} + onClear={() => { + setCurrentTagsDisplayValue(\\"\\"); + }} + onChange={(e) => { + let { value } = e.target; + fetchTagsRecords(value); + if (errors.tags?.hasError) { + runValidationTasks(\\"tags\\", value); + } + setCurrentTagsDisplayValue(value); + setCurrentTagsValue(undefined); + }} + onBlur={() => runValidationTasks(\\"tags\\", currentTagsDisplayValue)} + errorMessage={errors.tags?.errorMessage} + hasError={errors.tags?.hasError} + ref={tagsRef} + labelHidden={true} + {...getOverrideProps(overrides, \\"tags\\")} + ></Autocomplete> + </ArrayField> + <Flex + justifyContent=\\"space-between\\" + {...getOverrideProps(overrides, \\"CTAFlex\\")} + > + <Button + children=\\"Reset\\" + type=\\"reset\\" + onClick={(event) => { + event.preventDefault(); + resetStateValues(); + }} + isDisabled={!(idProp || movieModelProp)} + {...getOverrideProps(overrides, \\"ResetButton\\")} + ></Button> + <Flex + gap=\\"15px\\" + {...getOverrideProps(overrides, \\"RightAlignCTASubFlex\\")} + > + <Button + children=\\"Submit\\" + type=\\"submit\\" + variation=\\"primary\\" + isDisabled={ + !(idProp || movieModelProp) || + Object.values(errors).some((e) => e?.hasError) + } + {...getOverrideProps(overrides, \\"SubmitButton\\")} + ></Button> + </Flex> + </Flex> + </Grid> + ); +} +" +`; + +exports[`amplify form renderer tests GraphQL form tests should generate an update form with composite primary key 2`] = ` +"import * as React from \\"react\\"; +import { AutocompleteProps, GridProps, TextFieldProps } from \\"@aws-amplify/ui-react\\"; +import { EscapeHatchProps } from \\"@aws-amplify/ui-react/internal\\"; +import { Movie, Tag } from \\"../API\\"; +export declare type ValidationResponse = { + hasError: boolean; + errorMessage?: string; +}; +export declare type ValidationFunction<T> = (value: T, validationResponse: ValidationResponse) => ValidationResponse | Promise<ValidationResponse>; +export declare type MovieUpdateFormInputValues = { + movieKey?: string; + title?: string; + genre?: string; + rating?: string; + tags?: Tag[]; +}; +export declare type MovieUpdateFormValidationValues = { + movieKey?: ValidationFunction<string>; + title?: ValidationFunction<string>; + genre?: ValidationFunction<string>; + rating?: ValidationFunction<string>; + tags?: ValidationFunction<Tag>; +}; +export declare type PrimitiveOverrideProps<T> = Partial<T> & React.DOMAttributes<HTMLDivElement>; +export declare type MovieUpdateFormOverridesProps = { + MovieUpdateFormGrid?: PrimitiveOverrideProps<GridProps>; + movieKey?: PrimitiveOverrideProps<TextFieldProps>; + title?: PrimitiveOverrideProps<TextFieldProps>; + genre?: PrimitiveOverrideProps<TextFieldProps>; + rating?: PrimitiveOverrideProps<TextFieldProps>; + tags?: PrimitiveOverrideProps<AutocompleteProps>; +} & EscapeHatchProps; +export declare type MovieUpdateFormProps = React.PropsWithChildren<{ + overrides?: MovieUpdateFormOverridesProps | undefined | null; +} & { + id?: { + movieKey: string; + title: string; + genre: string; + }; + movie?: Movie; + onSubmit?: (fields: MovieUpdateFormInputValues) => MovieUpdateFormInputValues; + onSuccess?: (fields: MovieUpdateFormInputValues) => void; + onError?: (fields: MovieUpdateFormInputValues, errorMessage: string) => void; + onChange?: (fields: MovieUpdateFormInputValues) => MovieUpdateFormInputValues; + onValidate?: MovieUpdateFormValidationValues; +} & React.CSSProperties>; +export default function MovieUpdateForm(props: MovieUpdateFormProps): React.ReactElement; +" +`; + exports[`amplify form renderer tests GraphQL form tests should generate an update form with hasMany relationship 1`] = ` "/* eslint-disable */ import * as React from \\"react\\"; @@ -7516,7 +8278,7 @@ export default function ClassUpdateForm(props) { query: deleteStudentClass, variables: { input: { - input: studentClassRecords[i], + id: studentClassRecords[i].id, }, }, }) @@ -7971,7 +8733,7 @@ export default function UpdateCPKTeacherForm(props) { ? ( await API.graphql({ query: getCPKTeacher, - variables: { id: idProp }, + variables: { id: specialTeacherIdProp }, }) ).data.getCPKTeacher : cPKTeacherModelProp; @@ -8268,7 +9030,7 @@ export default function UpdateCPKTeacherForm(props) { query: deleteCPKTeacherCPKClass, variables: { input: { - input: cPKTeacherCPKClassRecords[i], + id: cPKTeacherCPKClassRecords[i].id, }, }, }) 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 2d2dc672..8059393b 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 @@ -843,6 +843,22 @@ describe('amplify form renderer tests', () => { expect(declaration).toMatchSnapshot(); }); + it('should generate an update form with composite primary key', () => { + const { componentText, declaration } = generateWithAmplifyFormRenderer( + 'forms/relationships/update-movie', + 'models/composite-key-movie', + { ...defaultCLIRenderConfig, ...rendererConfigWithGraphQL }, + { isNonModelSupported: true, isRelationshipSupported: true }, + ); + + // check for import statement for graphql operation + expect(componentText).not.toContain('DataStore'); + expect(componentText).toContain('variables: { ...idProp },'); + + expect(componentText).toMatchSnapshot(); + expect(declaration).toMatchSnapshot(); + }); + it('should generate an upgrade form with multiple relationship & cpk', () => { const { componentText, declaration } = generateWithAmplifyFormRenderer( 'forms/cpk-teacher-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 68917ef0..566cf1a5 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 @@ -678,8 +678,10 @@ export const buildUpdateDatastoreQuery = ( relatedModelStatements: Statement[], primaryKey: string, importCollection: ImportCollection, + isCompositeKey: boolean, dataApi?: DataApiKind, ) => { + // if there are multiple primaryKeys, it's a composite key and we're using 'id' for a composite key prop const pkQueryIdentifier = factory.createIdentifier(primaryKey); const queryCall = @@ -687,7 +689,9 @@ export const buildUpdateDatastoreQuery = ( ? wrapInParenthesizedExpression( getGraphqlCallExpression(ActionType.GET, importedModelName, importCollection, { inputs: [ - factory.createPropertyAssignment(factory.createIdentifier('id'), factory.createIdentifier('idProp')), + isCompositeKey + ? factory.createSpreadAssignment(pkQueryIdentifier) + : factory.createPropertyAssignment(factory.createIdentifier('id'), pkQueryIdentifier), ], }), ['data', getGraphqlQueryForModel(ActionType.GET, importedModelName)], 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 96dd411a..48731772 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 @@ -766,6 +766,7 @@ export const buildManyToManyRelationshipStatements = ( ], ), ), + // unlink many:many records factory.createExpressionStatement( factory.createCallExpression( factory.createPropertyAccessExpression( @@ -813,6 +814,7 @@ export const buildManyToManyRelationshipStatements = ( ], ), ), + // link many:many records factory.createExpressionStatement( factory.createCallExpression( factory.createPropertyAccessExpression( @@ -2192,10 +2194,13 @@ function buildUnlinkForEachBlock( ? getGraphqlCallExpression(ActionType.DELETE, relatedJoinTableName, importCollection, { inputs: [ factory.createPropertyAssignment( - factory.createIdentifier('input'), - factory.createElementAccessExpression( - factory.createIdentifier(getRecordsName(relatedJoinTableName)), - factory.createIdentifier('i'), + factory.createIdentifier('id'), + factory.createPropertyAccessExpression( + factory.createElementAccessExpression( + factory.createIdentifier(getRecordsName(relatedJoinTableName)), + factory.createIdentifier('i'), + ), + factory.createIdentifier('id'), ), ), ], 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 b01905af..6757110a 100644 --- a/packages/codegen-ui-react/lib/forms/react-form-renderer.ts +++ b/packages/codegen-ui-react/lib/forms/react-form-renderer.ts @@ -531,8 +531,10 @@ export abstract class ReactFormTemplateRenderer extends StudioTemplateRenderer< // primaryKey should exist if DataStore update form. This condition is just for ts if (this.primaryKeys) { // if there are multiple primaryKeys, it's a composite key and we're using 'id' for a composite key prop - const destructuredPrimaryKey = - this.primaryKeys.length > 1 ? getPropName(COMPOSITE_PRIMARY_KEY_PROP_NAME) : getPropName(this.primaryKeys[0]); + const isCompositeKey = this.primaryKeys.length > 1; + const destructuredPrimaryKey = isCompositeKey + ? getPropName(COMPOSITE_PRIMARY_KEY_PROP_NAME) + : getPropName(this.primaryKeys[0]); statements.push( addUseEffectWrapper( buildUpdateDatastoreQuery( @@ -541,6 +543,7 @@ export abstract class ReactFormTemplateRenderer extends StudioTemplateRenderer< relatedModelStatements, destructuredPrimaryKey, this.importCollection, + isCompositeKey, dataApi, ), [destructuredPrimaryKey, getModelNameProp(lowerCaseDataTypeName)], diff --git a/packages/codegen-ui/example-schemas/forms/relationships/update-movie.json b/packages/codegen-ui/example-schemas/forms/relationships/update-movie.json new file mode 100644 index 00000000..e6c2a473 --- /dev/null +++ b/packages/codegen-ui/example-schemas/forms/relationships/update-movie.json @@ -0,0 +1,12 @@ +{ + "name": "MovieUpdateForm", + "formActionType": "update", + "fields": {}, + "dataType": { + "dataSourceType": "DataStore", + "dataTypeName": "Movie" + }, + "style": {}, + "sectionalElements": {}, + "cta": {} +} \ No newline at end of file diff --git a/packages/codegen-ui/example-schemas/models/composite-key-movie.json b/packages/codegen-ui/example-schemas/models/composite-key-movie.json new file mode 100644 index 00000000..f8c365ef --- /dev/null +++ b/packages/codegen-ui/example-schemas/models/composite-key-movie.json @@ -0,0 +1,266 @@ +{ + "models": { + "Movie": { + "name": "Movie", + "fields": { + "movieKey": { + "name": "movieKey", + "isArray": false, + "type": "ID", + "isRequired": true, + "attributes": [] + }, + "title": { + "name": "title", + "isArray": false, + "type": "String", + "isRequired": true, + "attributes": [] + }, + "genre": { + "name": "genre", + "isArray": false, + "type": "String", + "isRequired": true, + "attributes": [] + }, + "rating": { + "name": "rating", + "isArray": false, + "type": "String", + "isRequired": false, + "attributes": [] + }, + "tags": { + "name": "tags", + "isArray": true, + "type": { + "model": "MovieTags" + }, + "isRequired": false, + "attributes": [], + "isArrayNullable": true, + "association": { + "connectionType": "HAS_MANY", + "associatedWith": [ + "movie" + ] + } + }, + "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": "Movies", + "attributes": [ + { + "type": "model", + "properties": {} + }, + { + "type": "key", + "properties": { + "fields": [ + "movieKey", + "title", + "genre" + ] + } + } + ] + }, + "Tag": { + "name": "Tag", + "fields": { + "id": { + "name": "id", + "isArray": false, + "type": "ID", + "isRequired": true, + "attributes": [] + }, + "label": { + "name": "label", + "isArray": false, + "type": "String", + "isRequired": true, + "attributes": [] + }, + "movies": { + "name": "movies", + "isArray": true, + "type": { + "model": "MovieTags" + }, + "isRequired": false, + "attributes": [], + "isArrayNullable": true, + "association": { + "connectionType": "HAS_MANY", + "associatedWith": [ + "tag" + ] + } + }, + "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": "Tags", + "attributes": [ + { + "type": "model", + "properties": {} + } + ] + }, + "MovieTags": { + "name": "MovieTags", + "fields": { + "id": { + "name": "id", + "isArray": false, + "type": "ID", + "isRequired": true, + "attributes": [] + }, + "tagId": { + "name": "tagId", + "isArray": false, + "type": "ID", + "isRequired": false, + "attributes": [] + }, + "movieMovieKey": { + "name": "movieMovieKey", + "isArray": false, + "type": "ID", + "isRequired": false, + "attributes": [] + }, + "movietitle": { + "name": "movietitle", + "isArray": false, + "type": "String", + "isRequired": false, + "attributes": [] + }, + "moviegenre": { + "name": "moviegenre", + "isArray": false, + "type": "String", + "isRequired": false, + "attributes": [] + }, + "tag": { + "name": "tag", + "isArray": false, + "type": { + "model": "Tag" + }, + "isRequired": true, + "attributes": [], + "association": { + "connectionType": "BELONGS_TO", + "targetNames": [ + "tagId" + ] + } + }, + "movie": { + "name": "movie", + "isArray": false, + "type": { + "model": "Movie" + }, + "isRequired": true, + "attributes": [], + "association": { + "connectionType": "BELONGS_TO", + "targetNames": [ + "movieMovieKey", + "movietitle", + "moviegenre" + ] + } + }, + "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": "MovieTags", + "attributes": [ + { + "type": "model", + "properties": {} + }, + { + "type": "key", + "properties": { + "name": "byTag", + "fields": [ + "tagId" + ] + } + }, + { + "type": "key", + "properties": { + "name": "byMovie", + "fields": [ + "movieMovieKey", + "movietitle", + "moviegenre" + ] + } + } + ] + } + }, + "enums": {}, + "nonModels": {}, + "codegenVersion": "3.4.3", + "version": "722b578d5541331db4e75c2857de5eaa" +} \ No newline at end of file