From 04b3d2700726cbc4d57648a55dad9d8b6d495371 Mon Sep 17 00:00:00 2001 From: Dane Pilcher Date: Mon, 31 Jan 2022 16:29:04 -0700 Subject: [PATCH] feat: add mutations (#371) * feat: add mutation types * feat: mutations * fix: add properties check to address failing integ test * chore: update types and remove replacing flat call with flatMap Co-authored-by: Alexander Harris --- .../studio-ui-codegen-react.test.ts.snap | 98 +++++++++++ .../__tests__/studio-ui-codegen-react.test.ts | 10 ++ .../__snapshots__/mutation.test.ts.snap | 22 +++ .../lib/__tests__/workflow/mutation.test.ts | 95 +++++++++++ .../amplify-ui-renderers/amplify-renderer.ts | 76 ++++++++- .../lib/react-component-render-helper.ts | 62 +++++++ .../lib/react-component-renderer.ts | 41 ++++- .../react-component-with-children-renderer.ts | 33 +++- .../lib/react-studio-template-renderer.ts | 19 ++- .../codegen-ui-react/lib/workflow/action.ts | 85 ++++++---- .../codegen-ui-react/lib/workflow/index.ts | 1 + .../codegen-ui-react/lib/workflow/mutation.ts | 158 ++++++++++++++++++ .../example-schemas/workflow/form.json | 46 +++++ .../workflow/internalMutation.json | 40 +++++ packages/codegen-ui/lib/renderer-helper.ts | 4 +- packages/codegen-ui/lib/types/studio-types.ts | 54 +++--- 16 files changed, 751 insertions(+), 93 deletions(-) create mode 100644 packages/codegen-ui-react/lib/__tests__/workflow/__snapshots__/mutation.test.ts.snap create mode 100644 packages/codegen-ui-react/lib/__tests__/workflow/mutation.test.ts create mode 100644 packages/codegen-ui-react/lib/workflow/mutation.ts create mode 100644 packages/codegen-ui/example-schemas/workflow/form.json create mode 100644 packages/codegen-ui/example-schemas/workflow/internalMutation.json 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 7352a4182..ac30ff607 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 @@ -5159,6 +5159,104 @@ export default function SocialA(props: SocialAProps): React.ReactElement { " `; +exports[`amplify render tests mutations form 1`] = ` +Object { + "componentText": "/* eslint-disable */ +import React from \\"react\\"; +import { + EscapeHatchProps, + getOverrideProps, + useDataStoreUpdateAction, +} from \\"@aws-amplify/ui-react/internal\\"; +import { Button, Flex, FlexProps, TextField } from \\"@aws-amplify/ui-react\\"; +import { Customer } from \\"../models\\"; + +export type MyFormProps = React.PropsWithChildren< + Partial & { + overrides?: EscapeHatchProps | undefined | null; + } +>; +export default function MyForm(props: MyFormProps): React.ReactElement { + const { overrides, ...rest } = props; + const [usernameTextFieldValue, setUsernameTextFieldValue] = + useStateMutationAction(\\"vizsla\\"); + const submitButtonClick = useDataStoreUpdateAction({ + model: Customer, + id: \\"d9887268-47dd-4899-9568-db5809218751\\", + fields: { username: usernameTextFieldValue }, + }); + return ( + /* @ts-ignore: TS2322 */ + + + + + ); +} +", + "declaration": undefined, + "renderComponentToFilesystem": [Function], +} +`; + +exports[`amplify render tests mutations internal mutation 1`] = ` +Object { + "componentText": "/* eslint-disable */ +import React from \\"react\\"; +import { + EscapeHatchProps, + getOverrideProps, +} from \\"@aws-amplify/ui-react/internal\\"; +import { Button, Flex, FlexProps } from \\"@aws-amplify/ui-react\\"; + +export type ColorChangeOnClickProps = React.PropsWithChildren< + Partial & { + overrides?: EscapeHatchProps | undefined | null; + } +>; +export default function ColorChangeOnClick( + props: ColorChangeOnClickProps +): React.ReactElement { + const { overrides, ...rest } = props; + const [coloredBoxBackgroundColor, setColoredBoxBackgroundColor] = + useStateMutationAction(\\"red\\"); + const colorChangerButtonClick = () => { + setColoredBoxBackgroundColor(\\"blue\\"); + }; + return ( + /* @ts-ignore: TS2322 */ + + + + + ); +} +", + "declaration": undefined, + "renderComponentToFilesystem": [Function], +} +`; + exports[`amplify render tests primitives Built-in Iconset 1`] = ` "/* eslint-disable */ import React from \\"react\\"; 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 1321aa837..ee9e37a82 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 @@ -329,6 +329,16 @@ describe('amplify render tests', () => { expect(generateWithAmplifyRenderer('workflow/event')).toMatchSnapshot(); }); + describe('mutations', () => { + it('form', () => { + expect(generateWithAmplifyRenderer('workflow/form')).toMatchSnapshot(); + }); + + it('internal mutation', () => { + expect(generateWithAmplifyRenderer('workflow/internalMutation')).toMatchSnapshot(); + }); + }); + describe('default value', () => { it('should render bound default value', () => { expect(generateWithAmplifyRenderer('default-value-components/boundDefaultValue')).toMatchSnapshot(); diff --git a/packages/codegen-ui-react/lib/__tests__/workflow/__snapshots__/mutation.test.ts.snap b/packages/codegen-ui-react/lib/__tests__/workflow/__snapshots__/mutation.test.ts.snap new file mode 100644 index 000000000..ddc263039 --- /dev/null +++ b/packages/codegen-ui-react/lib/__tests__/workflow/__snapshots__/mutation.test.ts.snap @@ -0,0 +1,22 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`getActionStateParameters basic 1`] = ` +Array [ + Object { + "componentName": "ColoredBox", + "property": "backgroundColor", + "set": Object { + "value": "something", + }, + }, +] +`; + +exports[`getComponentStateReferences basic 1`] = ` +Array [ + Object { + "componentName": "UserNameTextField", + "property": "value", + }, +] +`; diff --git a/packages/codegen-ui-react/lib/__tests__/workflow/mutation.test.ts b/packages/codegen-ui-react/lib/__tests__/workflow/mutation.test.ts new file mode 100644 index 000000000..498adcee9 --- /dev/null +++ b/packages/codegen-ui-react/lib/__tests__/workflow/mutation.test.ts @@ -0,0 +1,95 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +import { MutationAction, DataStoreUpdateItemAction } from '@aws-amplify/codegen-ui'; +import { getComponentStateReferences, getActionStateParameters } from '../../workflow/mutation'; + +describe('getComponentStateReferences', () => { + test('basic', () => { + const clickEvent: DataStoreUpdateItemAction = { + action: 'Amplify.DataStoreUpdateItemAction', + parameters: { + model: 'Customer', + id: { + value: 'd9887268-47dd-4899-9568-db5809218751', + }, + fields: { + username: { + componentName: 'UserNameTextField', + property: 'value', + }, + }, + }, + }; + + const component = { + id: '1234-5678-9010', + componentType: 'Flex', + name: 'MyForm', + properties: {}, + bindingProperties: {}, + children: [ + { + componentType: 'TextField', + name: 'UsernameTextField', + properties: { + label: { + value: 'Username', + }, + value: { + value: 'vizsla', + }, + }, + bindingProperties: {}, + }, + { + componentType: 'Button', + name: 'SubmitButton', + properties: { + label: { + value: 'Username', + }, + value: { + value: 'vizsla', + }, + }, + bindingProperties: {}, + events: { + click: clickEvent, + }, + }, + ], + }; + expect(getComponentStateReferences(component)).toMatchSnapshot(); + }); +}); + +describe('getActionStateParameters', () => { + test('basic', () => { + const action: MutationAction = { + action: 'Amplify.Mutation', + parameters: { + state: { + componentName: 'ColoredBox', + property: 'backgroundColor', + set: { + value: 'something', + }, + }, + }, + }; + expect(getActionStateParameters(action)).toMatchSnapshot(); + }); +}); diff --git a/packages/codegen-ui-react/lib/amplify-ui-renderers/amplify-renderer.ts b/packages/codegen-ui-react/lib/amplify-ui-renderers/amplify-renderer.ts index b3d2d1b81..914755c4a 100644 --- a/packages/codegen-ui-react/lib/amplify-ui-renderers/amplify-renderer.ts +++ b/packages/codegen-ui-react/lib/amplify-ui-renderers/amplify-renderer.ts @@ -88,6 +88,7 @@ export class AmplifyRenderer extends ReactStudioTemplateRenderer { const renderedComponent = new ReactComponentWithChildrenRenderer( component, + this.stateReferences, this.importCollection, parent, ).renderElement(renderChildren); @@ -104,6 +105,7 @@ export class AmplifyRenderer extends ReactStudioTemplateRenderer { case Primitive.Alert: return new ReactComponentWithChildrenRenderer( component, + this.stateReferences, this.importCollection, parent, ).renderElement(renderChildren); @@ -111,6 +113,7 @@ export class AmplifyRenderer extends ReactStudioTemplateRenderer { case Primitive.Badge: return new ReactComponentWithChildrenRenderer( component, + this.stateReferences, this.importCollection, parent, ).renderElement(renderChildren); @@ -118,6 +121,7 @@ export class AmplifyRenderer extends ReactStudioTemplateRenderer { case Primitive.Button: return new ReactComponentWithChildrenRenderer( component, + this.stateReferences, this.importCollection, parent, ).renderElement(renderChildren); @@ -125,6 +129,7 @@ export class AmplifyRenderer extends ReactStudioTemplateRenderer { case Primitive.ButtonGroup: return new ReactComponentWithChildrenRenderer( component, + this.stateReferences, this.importCollection, parent, ).renderElement(renderChildren); @@ -132,6 +137,7 @@ export class AmplifyRenderer extends ReactStudioTemplateRenderer { case Primitive.Card: return new ReactComponentWithChildrenRenderer( component, + this.stateReferences, this.importCollection, parent, ).renderElement(renderChildren); @@ -139,19 +145,28 @@ export class AmplifyRenderer extends ReactStudioTemplateRenderer { case Primitive.CheckboxField: return new ReactComponentWithChildrenRenderer( component, + this.stateReferences, this.importCollection, parent, ).renderElement(renderChildren); case Primitive.Collection: - return new CollectionRenderer(component, this.importCollection, parent).renderElement(renderChildren); + return new CollectionRenderer(component, this.stateReferences, this.importCollection, parent).renderElement( + renderChildren, + ); case Primitive.Divider: - return new ReactComponentRenderer(component, this.importCollection, parent).renderElement(); + return new ReactComponentRenderer( + component, + this.stateReferences, + this.importCollection, + parent, + ).renderElement(); case Primitive.Expander: return new ReactComponentWithChildrenRenderer( component, + this.stateReferences, this.importCollection, parent, ).renderElement(renderChildren); @@ -159,6 +174,7 @@ export class AmplifyRenderer extends ReactStudioTemplateRenderer { case Primitive.ExpanderItem: return new ReactComponentWithChildrenRenderer( component, + this.stateReferences, this.importCollection, parent, ).renderElement(renderChildren); @@ -166,6 +182,7 @@ export class AmplifyRenderer extends ReactStudioTemplateRenderer { case Primitive.Flex: return new ReactComponentWithChildrenRenderer( component, + this.stateReferences, this.importCollection, parent, ).renderElement(renderChildren); @@ -173,6 +190,7 @@ export class AmplifyRenderer extends ReactStudioTemplateRenderer { case Primitive.Grid: return new ReactComponentWithChildrenRenderer( component, + this.stateReferences, this.importCollection, parent, ).renderElement(renderChildren); @@ -180,6 +198,7 @@ export class AmplifyRenderer extends ReactStudioTemplateRenderer { case Primitive.Heading: return new ReactComponentWithChildrenRenderer( component, + this.stateReferences, this.importCollection, parent, ).renderElement(renderChildren); @@ -187,16 +206,23 @@ export class AmplifyRenderer extends ReactStudioTemplateRenderer { case Primitive.Icon: return new ReactComponentWithChildrenRenderer( component, + this.stateReferences, this.importCollection, parent, ).renderElement(renderChildren); case Primitive.Image: - return new ReactComponentRenderer(component, this.importCollection, parent).renderElement(); + return new ReactComponentRenderer( + component, + this.stateReferences, + this.importCollection, + parent, + ).renderElement(); case Primitive.Link: return new ReactComponentWithChildrenRenderer( component, + this.stateReferences, this.importCollection, parent, ).renderElement(renderChildren); @@ -204,6 +230,7 @@ export class AmplifyRenderer extends ReactStudioTemplateRenderer { case Primitive.Loader: return new ReactComponentWithChildrenRenderer( component, + this.stateReferences, this.importCollection, parent, ).renderElement(renderChildren); @@ -211,6 +238,7 @@ export class AmplifyRenderer extends ReactStudioTemplateRenderer { case Primitive.MenuButton: return new ReactComponentWithChildrenRenderer( component, + this.stateReferences, this.importCollection, parent, ).renderElement(renderChildren); @@ -218,6 +246,7 @@ export class AmplifyRenderer extends ReactStudioTemplateRenderer { case Primitive.MenuItem: return new ReactComponentWithChildrenRenderer( component, + this.stateReferences, this.importCollection, parent, ).renderElement(renderChildren); @@ -225,6 +254,7 @@ export class AmplifyRenderer extends ReactStudioTemplateRenderer { case Primitive.Menu: return new ReactComponentWithChildrenRenderer( component, + this.stateReferences, this.importCollection, parent, ).renderElement(renderChildren); @@ -232,6 +262,7 @@ export class AmplifyRenderer extends ReactStudioTemplateRenderer { case Primitive.Pagination: return new ReactComponentWithChildrenRenderer( component, + this.stateReferences, this.importCollection, parent, ).renderElement(renderChildren); @@ -239,6 +270,7 @@ export class AmplifyRenderer extends ReactStudioTemplateRenderer { case Primitive.PasswordField: return new ReactComponentWithChildrenRenderer( component, + this.stateReferences, this.importCollection, parent, ).renderElement(renderChildren); @@ -246,6 +278,7 @@ export class AmplifyRenderer extends ReactStudioTemplateRenderer { case Primitive.PhoneNumberField: return new ReactComponentWithChildrenRenderer( component, + this.stateReferences, this.importCollection, parent, ).renderElement(renderChildren); @@ -253,6 +286,7 @@ export class AmplifyRenderer extends ReactStudioTemplateRenderer { case Primitive.Placeholder: return new ReactComponentWithChildrenRenderer( component, + this.stateReferences, this.importCollection, parent, ).renderElement(renderChildren); @@ -260,6 +294,7 @@ export class AmplifyRenderer extends ReactStudioTemplateRenderer { case Primitive.Radio: return new ReactComponentWithChildrenRenderer( component, + this.stateReferences, this.importCollection, parent, ).renderElement(renderChildren); @@ -267,6 +302,7 @@ export class AmplifyRenderer extends ReactStudioTemplateRenderer { case Primitive.RadioGroupField: return new ReactComponentWithChildrenRenderer( component, + this.stateReferences, this.importCollection, parent, ).renderElement(renderChildren); @@ -274,6 +310,7 @@ export class AmplifyRenderer extends ReactStudioTemplateRenderer { case Primitive.Rating: return new ReactComponentWithChildrenRenderer( component, + this.stateReferences, this.importCollection, parent, ).renderElement(renderChildren); @@ -281,6 +318,7 @@ export class AmplifyRenderer extends ReactStudioTemplateRenderer { case Primitive.ScrollView: return new ReactComponentWithChildrenRenderer( component, + this.stateReferences, this.importCollection, parent, ).renderElement(renderChildren); @@ -288,6 +326,7 @@ export class AmplifyRenderer extends ReactStudioTemplateRenderer { case Primitive.SearchField: return new ReactComponentWithChildrenRenderer( component, + this.stateReferences, this.importCollection, parent, ).renderElement(renderChildren); @@ -295,16 +334,23 @@ export class AmplifyRenderer extends ReactStudioTemplateRenderer { case Primitive.SelectField: return new ReactComponentWithChildrenRenderer( component, + this.stateReferences, this.importCollection, parent, ).renderElement(renderChildren); case Primitive.SliderField: - return new ReactComponentRenderer(component, this.importCollection, parent).renderElement(); + return new ReactComponentRenderer( + component, + this.stateReferences, + this.importCollection, + parent, + ).renderElement(); case Primitive.StepperField: return new ReactComponentWithChildrenRenderer( component, + this.stateReferences, this.importCollection, parent, ).renderElement(renderChildren); @@ -312,6 +358,7 @@ export class AmplifyRenderer extends ReactStudioTemplateRenderer { case Primitive.SwitchField: return new ReactComponentWithChildrenRenderer( component, + this.stateReferences, this.importCollection, parent, ).renderElement(renderChildren); @@ -319,6 +366,7 @@ export class AmplifyRenderer extends ReactStudioTemplateRenderer { case Primitive.TabItem: return new ReactComponentWithChildrenRenderer( component, + this.stateReferences, this.importCollection, parent, ).renderElement(renderChildren); @@ -326,6 +374,7 @@ export class AmplifyRenderer extends ReactStudioTemplateRenderer { case Primitive.Tabs: return new ReactComponentWithChildrenRenderer( component, + this.stateReferences, this.importCollection, parent, ).renderElement(renderChildren); @@ -333,6 +382,7 @@ export class AmplifyRenderer extends ReactStudioTemplateRenderer { case Primitive.Table: return new ReactComponentWithChildrenRenderer( component, + this.stateReferences, this.importCollection, parent, ).renderElement(renderChildren); @@ -340,6 +390,7 @@ export class AmplifyRenderer extends ReactStudioTemplateRenderer { case Primitive.TableBody: return new ReactComponentWithChildrenRenderer( component, + this.stateReferences, this.importCollection, parent, ).renderElement(renderChildren); @@ -347,6 +398,7 @@ export class AmplifyRenderer extends ReactStudioTemplateRenderer { case Primitive.TableCell: return new ReactComponentWithChildrenRenderer( component, + this.stateReferences, this.importCollection, parent, ).renderElement(renderChildren); @@ -354,6 +406,7 @@ export class AmplifyRenderer extends ReactStudioTemplateRenderer { case Primitive.TableFoot: return new ReactComponentWithChildrenRenderer( component, + this.stateReferences, this.importCollection, parent, ).renderElement(renderChildren); @@ -361,6 +414,7 @@ export class AmplifyRenderer extends ReactStudioTemplateRenderer { case Primitive.TableHead: return new ReactComponentWithChildrenRenderer( component, + this.stateReferences, this.importCollection, parent, ).renderElement(renderChildren); @@ -368,6 +422,7 @@ export class AmplifyRenderer extends ReactStudioTemplateRenderer { case Primitive.TableRow: return new ReactComponentWithChildrenRenderer( component, + this.stateReferences, this.importCollection, parent, ).renderElement(renderChildren); @@ -375,6 +430,7 @@ export class AmplifyRenderer extends ReactStudioTemplateRenderer { case Primitive.Text: return new ReactComponentWithChildrenRenderer( component, + this.stateReferences, this.importCollection, parent, ).renderElement(renderChildren); @@ -382,6 +438,7 @@ export class AmplifyRenderer extends ReactStudioTemplateRenderer { case Primitive.TextField: return new ReactComponentWithChildrenRenderer>( component, + this.stateReferences, this.importCollection, parent, ).renderElement(renderChildren); @@ -389,6 +446,7 @@ export class AmplifyRenderer extends ReactStudioTemplateRenderer { case Primitive.ToggleButton: return new ReactComponentWithChildrenRenderer( component, + this.stateReferences, this.importCollection, parent, ).renderElement(renderChildren); @@ -396,6 +454,7 @@ export class AmplifyRenderer extends ReactStudioTemplateRenderer { case Primitive.ToggleButtonGroup: return new ReactComponentWithChildrenRenderer( component, + this.stateReferences, this.importCollection, parent, ).renderElement(renderChildren); @@ -403,6 +462,7 @@ export class AmplifyRenderer extends ReactStudioTemplateRenderer { case Primitive.View: return new ReactComponentWithChildrenRenderer( component, + this.stateReferences, this.importCollection, parent, ).renderElement(renderChildren); @@ -410,12 +470,18 @@ export class AmplifyRenderer extends ReactStudioTemplateRenderer { case Primitive.VisuallyHidden: return new ReactComponentWithChildrenRenderer( component, + this.stateReferences, this.importCollection, parent, ).renderElement(renderChildren); default: - return new CustomComponentRenderer(component, this.importCollection, parent).renderElement(renderChildren); + return new CustomComponentRenderer( + component, + this.stateReferences, + this.importCollection, + parent, + ).renderElement(renderChildren); } } } diff --git a/packages/codegen-ui-react/lib/react-component-render-helper.ts b/packages/codegen-ui-react/lib/react-component-render-helper.ts index e97890246..84aa1b3be 100644 --- a/packages/codegen-ui-react/lib/react-component-render-helper.ts +++ b/packages/codegen-ui-react/lib/react-component-render-helper.ts @@ -19,6 +19,7 @@ import { ConcatenatedStudioComponentProperty, ConditionalStudioComponentProperty, FixedStudioComponentProperty, + StateStudioComponentProperty, isAuthProperty, RelationalOperator, StudioComponent, @@ -28,6 +29,7 @@ import { StudioComponentEvent, BoundStudioComponentEvent, ActionStudioComponentEvent, + MutationActionSetStateParameter, } from '@aws-amplify/codegen-ui'; import { @@ -84,6 +86,14 @@ export function isConditionalProperty(prop: StudioComponentProperty): prop is Co return 'condition' in prop; } +export function isStateProperty(property: StudioComponentProperty): property is StateStudioComponentProperty { + return 'componentName' in property && 'property' in property; +} + +export function isSetStateParameter(parameter: StudioComponentProperty): parameter is MutationActionSetStateParameter { + return 'componentName' in parameter && 'property' in parameter && 'set' in parameter; +} + export function isDefaultValueOnly( prop: StudioComponentProperty, ): prop is CollectionStudioComponentProperty | BoundStudioComponentProperty { @@ -304,6 +314,45 @@ export function buildConcatAttr(prop: ConcatenatedStudioComponentProperty, propN return factory.createJsxAttribute(factory.createIdentifier(propName), factory.createJsxExpression(undefined, expr)); } +export function buildStateExpression(prop: StateStudioComponentProperty): Expression { + return factory.createIdentifier(getStateName(prop)); +} + +export function buildStateAttr(prop: StateStudioComponentProperty, propName: string): JsxAttribute { + const expr = buildStateExpression(prop); + return factory.createJsxAttribute(factory.createIdentifier(propName), factory.createJsxExpression(undefined, expr)); +} + +export function propertyToExpression(property: StudioComponentProperty): Expression { + if (isFixedPropertyWithValue(property)) { + return buildFixedLiteralExpression(property); + } + + if (isBoundProperty(property)) { + return property.defaultValue === undefined + ? buildBindingExpression(property) + : buildBindingWithDefaultExpression(property, property.defaultValue); + } + + if (isConcatenatedProperty(property)) { + return buildConcatExpression(property); + } + + if (isConditionalProperty(property)) { + return buildConditionalExpression(property); + } + + if (isStateProperty(property)) { + return buildStateExpression(property); + } + + if (isAuthProperty(property)) { + return buildAuthExpression(property); + } + + throw new Error(`Invalid property: ${JSON.stringify(property)}.`); +} + export function resolvePropToExpression(prop: StudioComponentProperty): Expression { if (isFixedPropertyWithValue(prop)) { const propValue = prop.value; @@ -511,3 +560,16 @@ export function addBindingPropertiesImports( }); } } + +export function getStateName(stateReference: StateStudioComponentProperty): string { + const { componentName, property } = stateReference; + return [ + componentName.charAt(0).toLowerCase() + componentName.slice(1), + property.charAt(0).toUpperCase() + property.slice(1), + ].join(''); +} + +export function getSetStateName(stateReference: StateStudioComponentProperty): string { + const stateName = getStateName(stateReference); + return ['set', stateName.charAt(0).toUpperCase() + stateName.slice(1)].join(''); +} diff --git a/packages/codegen-ui-react/lib/react-component-renderer.ts b/packages/codegen-ui-react/lib/react-component-renderer.ts index cda48ff7e..14f0b5080 100644 --- a/packages/codegen-ui-react/lib/react-component-renderer.ts +++ b/packages/codegen-ui-react/lib/react-component-renderer.ts @@ -13,7 +13,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { ComponentRendererBase, StudioNode, StudioComponent, StudioComponentChild } from '@aws-amplify/codegen-ui'; +import { + ComponentRendererBase, + StudioNode, + StudioComponent, + StudioComponentChild, + StateReference, +} from '@aws-amplify/codegen-ui'; import { JsxAttributeLike, JsxElement, @@ -22,9 +28,13 @@ import { JsxSelfClosingElement, SyntaxKind, } from 'typescript'; - -import { addBindingPropertiesImports, buildOpeningElementProperties } from './react-component-render-helper'; -import { buildOpeningElementEvents } from './workflow'; +import { + addBindingPropertiesImports, + buildOpeningElementProperties, + getStateName, + getSetStateName, +} from './react-component-render-helper'; +import { buildOpeningElementEvents, filterStateReferencesForComponent } from './workflow'; import { ImportCollection, ImportSource, ImportValue } from './imports'; export class ReactComponentRenderer extends ComponentRendererBase< @@ -33,6 +43,7 @@ export class ReactComponentRenderer extends ComponentRendererBase< > { constructor( component: StudioComponent | StudioComponentChild, + protected stateReferences: StateReference[], protected importCollection: ImportCollection, protected parent?: StudioNode, ) { @@ -53,13 +64,27 @@ export class ReactComponentRenderer extends ComponentRendererBase< } protected renderOpeningElement(): JsxOpeningElement { - const propertyAttributes = Object.entries(this.component.properties).map(([key, value]) => - buildOpeningElementProperties(value, key), - ); + const localStateReferences = filterStateReferencesForComponent(this.component, this.stateReferences); + const propertyAttributes = Object.entries(this.component.properties).map(([key, value]) => { + if (key in localStateReferences) { + const stateName = getStateName({ componentName: this.component.name || '', property: key }); + return buildOpeningElementProperties({ bindingProperties: { property: stateName } }, key); + } + return buildOpeningElementProperties(value, key); + }); const eventAttributes = Object.entries(this.component.events || {}).map(([key, value]) => buildOpeningElementEvents(value, key, this.component.name), ); - const attributes = propertyAttributes.concat(eventAttributes); + const controlEventAttributes = Object.entries(localStateReferences) + .filter(([, { addControlEvent }]) => addControlEvent) + .map(([key]) => + buildOpeningElementEvents( + { bindingEvent: getSetStateName({ componentName: this.component.name || '', property: key }) }, + 'change', // TODO: use component event mapping + this.component.name, + ), + ); + const attributes = propertyAttributes.concat(eventAttributes).concat(controlEventAttributes); this.addPropsSpreadAttributes(attributes); diff --git a/packages/codegen-ui-react/lib/react-component-with-children-renderer.ts b/packages/codegen-ui-react/lib/react-component-with-children-renderer.ts index 57efc7cab..bfa3d6d8f 100644 --- a/packages/codegen-ui-react/lib/react-component-with-children-renderer.ts +++ b/packages/codegen-ui-react/lib/react-component-with-children-renderer.ts @@ -18,11 +18,17 @@ import { StudioNode, StudioComponent, StudioComponentChild, + StateReference, } from '@aws-amplify/codegen-ui'; import { JsxAttributeLike, JsxElement, JsxChild, JsxOpeningElement, SyntaxKind, Expression, factory } from 'typescript'; import { ImportCollection, ImportSource, ImportValue } from './imports'; -import { addBindingPropertiesImports, buildOpeningElementProperties } from './react-component-render-helper'; -import { buildOpeningElementEvents } from './workflow'; +import { + addBindingPropertiesImports, + buildOpeningElementProperties, + getStateName, + getSetStateName, +} from './react-component-render-helper'; +import { buildOpeningElementEvents, filterStateReferencesForComponent } from './workflow'; import Primitive, { PrimitiveChildrenPropMapping } from './primitive'; export class ReactComponentWithChildrenRenderer extends ComponentWithChildrenRendererBase< @@ -32,6 +38,7 @@ export class ReactComponentWithChildrenRenderer extends ComponentWithCh > { constructor( component: StudioComponent | StudioComponentChild, + protected stateReferences: StateReference[], protected importCollection: ImportCollection, protected parent?: StudioNode, ) { @@ -55,13 +62,27 @@ export class ReactComponentWithChildrenRenderer extends ComponentWithCh } protected renderOpeningElement(): JsxOpeningElement { - const propertyAttributes = Object.entries(this.component.properties).map(([key, value]) => - buildOpeningElementProperties(value, key), - ); + const localStateReferences = filterStateReferencesForComponent(this.component, this.stateReferences); + const propertyAttributes = Object.entries(this.component.properties).map(([key, value]) => { + if (key in localStateReferences) { + const stateName = getStateName({ componentName: this.component.name || '', property: key }); + return buildOpeningElementProperties({ bindingProperties: { property: stateName } }, key); + } + return buildOpeningElementProperties(value, key); + }); const eventAttributes = Object.entries(this.component.events || {}).map(([key, value]) => buildOpeningElementEvents(value, key, this.component.name), ); - const attributes = propertyAttributes.concat(eventAttributes); + const controlEventAttributes = Object.entries(localStateReferences) + .filter(([, { addControlEvent }]) => addControlEvent) + .map(([key]) => + buildOpeningElementEvents( + { bindingEvent: getSetStateName({ componentName: this.component.name || '', property: key }) }, + 'change', // TODO: use component event mapping + this.component.name, + ), + ); + const attributes = propertyAttributes.concat(eventAttributes).concat(controlEventAttributes); this.addPropsSpreadAttributes(attributes); 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 e0dfaa06f..7a65b554d 100644 --- a/packages/codegen-ui-react/lib/react-studio-template-renderer.ts +++ b/packages/codegen-ui-react/lib/react-studio-template-renderer.ts @@ -30,6 +30,8 @@ import { StudioComponentSimplePropertyBinding, handleCodegenErrors, StudioComponentChild, + StateStudioComponentProperty, + MutationActionSetStateParameter, } from '@aws-amplify/codegen-ui'; import { EOL } from 'os'; import ts, { @@ -76,7 +78,12 @@ import Primitive, { PrimitiveChildrenPropMapping, } from './primitive'; import { RequiredKeys } from './utils/type-utils'; -import { getComponentActions, buildUseActionStatement } from './workflow'; +import { + getComponentActions, + buildUseActionStatement, + getComponentStateReferences, + buildStateStatements, +} from './workflow'; export abstract class ReactStudioTemplateRenderer extends StudioTemplateRenderer< string, @@ -91,6 +98,8 @@ export abstract class ReactStudioTemplateRenderer extends StudioTemplateRenderer protected renderConfig: RequiredKeys; + stateReferences: (StateStudioComponentProperty | MutationActionSetStateParameter)[] = []; + fileName = `${this.component.name}.tsx`; constructor(component: StudioComponent, renderConfig: ReactRenderConfig) { @@ -101,6 +110,7 @@ export abstract class ReactStudioTemplateRenderer extends StudioTemplateRenderer }; this.fileName = `${this.component.name}.${scriptKindToFileExtension(this.renderConfig.script)}`; // TODO: throw warnings on invalid config combinations. i.e. CommonJS + JSX + this.stateReferences = getComponentStateReferences(this.component); this.mapSyntheticPropsForVariants(); } @@ -277,7 +287,7 @@ export abstract class ReactStudioTemplateRenderer extends StudioTemplateRenderer } renderSampleCodeSnippetJsx(component: StudioComponent): JsxElement | JsxFragment | JsxSelfClosingElement { - return new SampleCodeRenderer(component, this.importCollection).renderElement(); + return new SampleCodeRenderer(component, this.stateReferences, this.importCollection).renderElement(); } renderBindingPropsType(component: StudioComponent): TypeAliasDeclaration { @@ -580,6 +590,11 @@ export abstract class ReactStudioTemplateRenderer extends StudioTemplateRenderer statements.push(this.buildOverridesFromVariantsAndProp()); } + const stateStatements = buildStateStatements(component, this.stateReferences); + stateStatements.forEach((entry) => { + statements.push(entry); + }); + const authStatement = this.buildUseAuthenticatedUserStatement(component); if (authStatement !== undefined) { this.importCollection.addMappedImport(ImportValue.USE_AUTH); diff --git a/packages/codegen-ui-react/lib/workflow/action.ts b/packages/codegen-ui-react/lib/workflow/action.ts index 9b86fe512..ee66877b4 100644 --- a/packages/codegen-ui-react/lib/workflow/action.ts +++ b/packages/codegen-ui-react/lib/workflow/action.ts @@ -21,19 +21,9 @@ import { StudioComponentChild, ActionStudioComponentEvent, StudioComponentProperty, + MutationAction, } from '@aws-amplify/codegen-ui'; -import { - isFixedPropertyWithValue, - isBoundProperty, - isConcatenatedProperty, - isConditionalProperty, - buildBindingExpression, - buildBindingWithDefaultExpression, - buildConcatExpression, - buildConditionalExpression, - buildFixedLiteralExpression, - isActionEvent, -} from '../react-component-render-helper'; +import { isActionEvent, propertyToExpression, getSetStateName } from '../react-component-render-helper'; import { ImportCollection, ImportSource } from '../imports'; enum Action { @@ -42,6 +32,7 @@ enum Action { 'Amplify.DataStoreUpdateItem' = 'Amplify.DataStoreUpdateItem', 'Amplify.DataStoreDeleteItem' = 'Amplify.DataStoreDeleteItem', 'Amplify.AuthSignOut' = 'Amplify.AuthSignOut', + 'Amplify.Mutation' = 'Amplify.Mutation', } export default Action; @@ -52,12 +43,17 @@ export const ActionNameMapping: Partial> = { [Action['Amplify.DataStoreUpdateItem']]: 'useDataStoreUpdateAction', [Action['Amplify.DataStoreDeleteItem']]: 'useDataStoreDeleteAction', [Action['Amplify.AuthSignOut']]: 'useAuthSignOutAction', + [Action['Amplify.Mutation']]: 'useStateMutationAction', }; export function isAction(action: string): action is Action { return Object.values(Action).includes(action as Action); } +export function isMutationAction(action: ActionStudioComponentEvent): action is MutationAction { + return (action.action as Action) === Action['Amplify.Mutation']; +} + export function getActionHookName(action: string): string { const actionName = ActionNameMapping[Action[action as Action]]; if (actionName === undefined) { @@ -95,6 +91,10 @@ export function buildUseActionStatement( identifier: string, importCollection: ImportCollection, ): Statement { + if (isMutationAction(action)) { + return buildMutationActionStatement(action, identifier); + } + const actionHookName = getActionHookName(action.action); importCollection.addImport(ImportSource.UI_REACT_INTERNAL, actionHookName); return factory.createVariableStatement( @@ -115,6 +115,41 @@ export function buildUseActionStatement( ); } +export function buildMutationActionStatement(action: MutationAction, identifier: string) { + const setState = getSetStateName(action.parameters.state); + + return factory.createVariableStatement( + undefined, + factory.createVariableDeclarationList( + [ + factory.createVariableDeclaration( + factory.createIdentifier(identifier), + undefined, + undefined, + factory.createArrowFunction( + undefined, + undefined, + [], + undefined, + factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), + factory.createBlock( + [ + factory.createExpressionStatement( + factory.createCallExpression(factory.createIdentifier(setState), undefined, [ + propertyToExpression(action.parameters.state.set), + ]), + ), + ], + true, + ), + ), + ), + ], + ts.NodeFlags.Const, + ), + ); +} + /* Transform the action parameters field to literal * * model and fields are special cases. All other fields are StudioComponentProperty @@ -159,29 +194,5 @@ export function getActionParameterValue( false, ); } - return actionParameterToExpression(value as StudioComponentProperty); -} - -export function actionParameterToExpression(parameter: StudioComponentProperty): Expression { - if (isFixedPropertyWithValue(parameter)) { - return buildFixedLiteralExpression(parameter); - } - - if (isBoundProperty(parameter)) { - return parameter.defaultValue === undefined - ? buildBindingExpression(parameter) - : buildBindingWithDefaultExpression(parameter, parameter.defaultValue); - } - - if (isConcatenatedProperty(parameter)) { - return buildConcatExpression(parameter); - } - - if (isConditionalProperty(parameter)) { - return buildConditionalExpression(parameter); - } - - // TODO add user specific attributes - - throw new Error(`Invalid action parameter: ${JSON.stringify(parameter)}.`); + return propertyToExpression(value as StudioComponentProperty); } diff --git a/packages/codegen-ui-react/lib/workflow/index.ts b/packages/codegen-ui-react/lib/workflow/index.ts index e14f402c0..cb1cd7b12 100644 --- a/packages/codegen-ui-react/lib/workflow/index.ts +++ b/packages/codegen-ui-react/lib/workflow/index.ts @@ -15,3 +15,4 @@ */ export * from './action'; export * from './events'; +export * from './mutation'; diff --git a/packages/codegen-ui-react/lib/workflow/mutation.ts b/packages/codegen-ui-react/lib/workflow/mutation.ts new file mode 100644 index 000000000..aaae4c781 --- /dev/null +++ b/packages/codegen-ui-react/lib/workflow/mutation.ts @@ -0,0 +1,158 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +import ts, { Statement, factory } from 'typescript'; +import { + StudioComponent, + StudioComponentChild, + StateStudioComponentProperty, + ActionStudioComponentEvent, + StateReference, +} from '@aws-amplify/codegen-ui'; +import { + isActionEvent, + isStateProperty, + isSetStateParameter, + propertyToExpression, + getStateName, + getSetStateName, +} from '../react-component-render-helper'; + +export function getComponentStateReferences(component: StudioComponent | StudioComponentChild) { + const stateReferences: StateReference[] = []; + + if (component.properties) { + Object.values(component.properties).forEach((property) => { + if (isStateProperty(property)) { + stateReferences.push(property); + } + }); + } + + if (component.events) { + stateReferences.push( + ...Object.values(component.events) + .filter((action): action is ActionStudioComponentEvent => isActionEvent(action)) + .flatMap((action) => getActionStateParameters(action)), + ); + } + + if (component.children) { + component.children.forEach((child) => { + stateReferences.push(...getComponentStateReferences(child)); + }); + } + + // TODO: dedupe state references + + return stateReferences; +} + +export function getActionStateParameters(action: ActionStudioComponentEvent): StateStudioComponentProperty[] { + if (action.parameters) { + return Object.entries(action.parameters) + .filter(([key]) => key !== 'model') + .flatMap(([key, parameter]) => { + if (key === 'fields' || key === 'attributes') { + return Object.values(parameter); + } + return parameter; + }) + .filter((parameter) => isStateProperty(parameter) || isSetStateParameter(parameter)); + } + return []; +} + +export function buildStateStatements(component: StudioComponent, stateReferences: StateReference[]): Statement[] { + return stateReferences.map((stateReference) => { + return factory.createVariableStatement( + undefined, + factory.createVariableDeclarationList( + [ + factory.createVariableDeclaration( + factory.createArrayBindingPattern([ + factory.createBindingElement( + undefined, + undefined, + factory.createIdentifier(getStateName(stateReference)), + undefined, + ), + factory.createBindingElement( + undefined, + undefined, + factory.createIdentifier(getSetStateName(stateReference)), + undefined, + ), + ]), + undefined, + undefined, + factory.createCallExpression(factory.createIdentifier('useStateMutationAction'), undefined, [ + getStateDefaultValue(component, stateReference), + ]), + ), + ], + ts.NodeFlags.Const, + ), + ); + }); +} + +export function getStateDefaultValue(component: StudioComponent, stateReference: StateStudioComponentProperty) { + const { componentName, property } = stateReference; + const referencedComponent = getComponentFromComponentTree(component, componentName); + const componentProperty = referencedComponent.properties[property]; + if (componentProperty === undefined) { + throw new Error(`Invalid state reference. Property ${property} does not exist on component ${componentName}`); + } + return propertyToExpression(componentProperty); +} + +export function getComponentFromComponentTree( + component: StudioComponent, + componentName: string, +): StudioComponent | StudioComponentChild { + const getComponentFromComponentTreeHelper = ( + currentComponent: StudioComponent | StudioComponentChild, + ): StudioComponent | StudioComponentChild | undefined => { + if (currentComponent.name === componentName) { + return currentComponent; + } + + if (currentComponent.children) { + return currentComponent.children.find( + (child: StudioComponentChild) => getComponentFromComponentTreeHelper(child) !== undefined, + ); + } + + return undefined; + }; + + const res = getComponentFromComponentTreeHelper(component); + + if (res === undefined) { + throw new Error(`Component ${componentName} not found in component tree ${component.name}`); + } + + return res; +} + +export function filterStateReferencesForComponent( + component: StudioComponent | StudioComponentChild, + stateReferences: StateReference[], +): { [property: string]: { addControlEvent: boolean } } { + return stateReferences + .filter(({ componentName }) => componentName === component.name) + .reduce((prev, reference) => ({ ...prev, [reference.property]: { addControlEvent: !('set' in reference) } }), {}); +} diff --git a/packages/codegen-ui/example-schemas/workflow/form.json b/packages/codegen-ui/example-schemas/workflow/form.json new file mode 100644 index 000000000..13b8fb762 --- /dev/null +++ b/packages/codegen-ui/example-schemas/workflow/form.json @@ -0,0 +1,46 @@ +{ + "id": "1234-5678-9010", + "componentType": "Flex", + "name": "MyForm", + "properties": {}, + "children": [ + { + "componentType": "TextField", + "name": "UsernameTextField", + "properties": { + "label": { + "value": "Username" + }, + "value": { + "value": "vizsla" + } + } + }, + { + "componentType": "Button", + "name": "SubmitButton", + "events": { + "click": { + "action": "Amplify.DataStoreUpdateItem", + "parameters": { + "model": "Customer", + "id": { + "value": "d9887268-47dd-4899-9568-db5809218751" + }, + "fields": { + "username": { + "componentName": "UsernameTextField", + "property": "value" + } + } + } + } + }, + "properties": { + "children": { + "value": "Submit" + } + } + } + ] +} diff --git a/packages/codegen-ui/example-schemas/workflow/internalMutation.json b/packages/codegen-ui/example-schemas/workflow/internalMutation.json new file mode 100644 index 000000000..5ccea9161 --- /dev/null +++ b/packages/codegen-ui/example-schemas/workflow/internalMutation.json @@ -0,0 +1,40 @@ +{ + "id": "1234-5678-9010", + "componentType": "Flex", + "name": "ColorChangeOnClick", + "properties": {}, + "children": [ + { + "componentType": "Flex", + "name": "ColoredBox", + "properties": { + "backgroundColor": { + "value": "red" + } + } + }, + { + "componentType": "Button", + "name": "ColorChangerButton", + "events": { + "click": { + "action": "Amplify.Mutation", + "parameters": { + "state": { + "componentName": "ColoredBox", + "property": "backgroundColor", + "set": { + "value": "blue" + } + } + } + } + }, + "properties": { + "children": { + "value": "Change Color" + } + } + } + ] +} diff --git a/packages/codegen-ui/lib/renderer-helper.ts b/packages/codegen-ui/lib/renderer-helper.ts index 1b4c6641f..1cdb6b892 100644 --- a/packages/codegen-ui/lib/renderer-helper.ts +++ b/packages/codegen-ui/lib/renderer-helper.ts @@ -19,7 +19,7 @@ import { ConcatenatedStudioComponentProperty, ConditionalStudioComponentProperty, FixedStudioComponentProperty, - FormStudioComponentProperty, + StateStudioComponentProperty, StudioComponent, StudioComponentAuthProperty, StudioComponentChild, @@ -51,7 +51,7 @@ export type ComponentPropertyValueTypes = | FixedStudioComponentProperty | BoundStudioComponentProperty | CollectionStudioComponentProperty - | FormStudioComponentProperty + | StateStudioComponentProperty | StudioComponentAuthProperty; export function isAuthProperty(prop: ComponentPropertyValueTypes): prop is StudioComponentAuthProperty { diff --git a/packages/codegen-ui/lib/types/studio-types.ts b/packages/codegen-ui/lib/types/studio-types.ts index c2702ca6a..e81076ce1 100644 --- a/packages/codegen-ui/lib/types/studio-types.ts +++ b/packages/codegen-ui/lib/types/studio-types.ts @@ -239,8 +239,8 @@ export type StudioComponentProperty = ( | CollectionStudioComponentProperty | ConcatenatedStudioComponentProperty | ConditionalStudioComponentProperty - | FormStudioComponentProperty | StudioComponentAuthProperty + | StateStudioComponentProperty ) & CommonPropertyValues; @@ -321,21 +321,9 @@ export type ConditionalStudioComponentProperty = { }; }; -/** - * This is the configuration for a form binding. This is - * technically an extension of Workflows but because it is - * pretty unique, it should be separated out with its own definition - */ -export type FormStudioComponentProperty = { - /** - * The model of the DataStore object - */ - model: string; - - /** - * The binding configuration for the form - */ - bindings: FormBindings; +export type StateStudioComponentProperty = { + componentName: string; + property: string; }; /** @@ -394,22 +382,6 @@ export type StudioComponentEventPropertyBinding = { type: 'Event'; }; -export type FormBindings = { - [key: string]: FormBindingElement; -}; - -export type FormBindingElement = { - /** - * The name of the component to fetch a value from - */ - element: string; - - /** - * The property component to get the value from. - */ - property: string; -}; - /** * This represent the configuration for binding a component property * to Amplify specific information @@ -522,7 +494,8 @@ export type ActionStudioComponentEvent = | AuthSignOutAction | DataStoreCreateItemAction | DataStoreUpdateItemAction - | DataStoreDeleteItemAction; + | DataStoreDeleteItemAction + | MutationAction; export type NavigationAction = { action: 'Amplify.Navigation'; @@ -570,6 +543,21 @@ export type DataStoreDeleteItemAction = { }; }; +export type MutationAction = { + action: 'Amplify.Mutation'; + parameters: { + state: MutationActionSetStateParameter; + }; +}; + +export type MutationActionSetStateParameter = { + componentName: string; + property: string; + set: StudioComponentProperty; +}; + +export type StateReference = StateStudioComponentProperty | MutationActionSetStateParameter; + export type StudioComponentEvents = { [eventName: string]: StudioComponentEvent; };