diff --git a/.changeset/quick-donuts-admire.md b/.changeset/quick-donuts-admire.md new file mode 100644 index 00000000000..5fcaea2914c --- /dev/null +++ b/.changeset/quick-donuts-admire.md @@ -0,0 +1,5 @@ +--- +"@aws-amplify/ui-react": patch +--- + +feat: Add Action/Workflow hooks diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 33f10f0f760..4265be5405f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -55,7 +55,7 @@ jobs: uses: actions/setup-node@v2 with: node-version: lts/* - cache: 'yarn' + cache: "yarn" - name: Install packages run: yarn --no-lockfile - name: Build ui package @@ -104,7 +104,7 @@ jobs: uses: actions/setup-node@v2 with: node-version: lts/* - cache: 'yarn' + cache: "yarn" - name: Restore node_modules cache uses: actions/cache@v2 @@ -162,15 +162,15 @@ jobs: include: - example: angular package: angular - tags: '@angular and not (@skip or @todo-angular)' + tags: "@angular and not (@skip or @todo-angular)" - example: next package: react - tags: '@react and not (@skip or @todo-react)' + tags: "@react and not (@skip or @todo-react)" - example: vue package: vue - tags: '@vue and not (@skip or @todo-vue)' + tags: "@vue and not (@skip or @todo-vue)" steps: - name: Checkout Amplify UI @@ -193,7 +193,7 @@ jobs: uses: actions/setup-node@v2 with: node-version: lts/* - cache: 'yarn' + cache: "yarn" - name: Restore cypress runner Cache uses: actions/cache@v2 @@ -265,10 +265,10 @@ jobs: run: yarn workspace ${{ matrix.example }}-example start & npx wait-on -c waitOnConfig.json -t 20000 http-get://localhost:3000/ui/components/authenticator/sign-in-with-username - name: Run E2E tests against ${{ matrix.example }} example - run: yarn workspace e2e test:authenticator + run: yarn workspace e2e test:examples env: # Override on the default value in `cypress.json` with framework-specific tag - TAGS: '${{ matrix.tags }}' + TAGS: "${{ matrix.tags }}" # Env values for testing flows DOMAIN: ${{ secrets.DOMAIN }} @@ -295,7 +295,7 @@ jobs: uses: actions/setup-node@v2 with: node-version: lts/* - cache: 'yarn' + cache: "yarn" - name: Restore cypress runner Cache uses: actions/cache@v2 diff --git a/environments/action-hooks/.gitignore b/environments/action-hooks/.gitignore new file mode 100644 index 00000000000..90fb3c0cc19 --- /dev/null +++ b/environments/action-hooks/.gitignore @@ -0,0 +1,20 @@ +#amplify-do-not-edit-begin +amplify/\#current-cloud-backend +amplify/.config/local-* +amplify/logs +amplify/mock-data +amplify/backend/amplify-meta.json +amplify/backend/.temp +build/ +dist/ +node_modules/ +aws-exports.js +awsconfiguration.json +amplifyconfiguration.json +amplifyconfiguration.dart +amplify-build-config.json +amplify-gradle-config.json +amplifytools.xcconfig +.secret-* +**.sample +#amplify-do-not-edit-end \ No newline at end of file diff --git a/environments/action-hooks/README.md b/environments/action-hooks/README.md new file mode 100644 index 00000000000..d9d27442236 --- /dev/null +++ b/environments/action-hooks/README.md @@ -0,0 +1,24 @@ +# Action Hooks + +This backend is configured with Amplify Admin UI: + +- Authentication + + - `Email` is a required attribute, if not using a social provider to sign in + - No screenshots are included as they contain secret IDs for the web apps + +## Using this Backend + +External contributors can re-create this backend by running: + +```shell +amplify pull +``` + +Internal (Amplify UI team) contributors can use this backend directly by running: + +- NOTE: do _not_ pass in the `--yes` flag, as that can sometimes cause issues with the CLI not recognizing certain existing environment variables + +```shell +amplify pull --appId d1r9l08y32q5fb --envName dev +``` diff --git a/environments/action-hooks/amplify/.config/project-config.json b/environments/action-hooks/amplify/.config/project-config.json new file mode 100644 index 00000000000..4de6bed9c10 --- /dev/null +++ b/environments/action-hooks/amplify/.config/project-config.json @@ -0,0 +1,17 @@ +{ + "projectName": "actionhooks", + "version": "3.1", + "frontend": "javascript", + "javascript": { + "framework": "none", + "config": { + "SourceDir": "src", + "DistributionDir": "dist", + "BuildCommand": "npm run-script build", + "StartCommand": "npm run-script start" + } + }, + "providers": [ + "awscloudformation" + ] +} \ No newline at end of file diff --git a/environments/action-hooks/amplify/backend/auth/actionhooks71568983/cli-inputs.json b/environments/action-hooks/amplify/backend/auth/actionhooks71568983/cli-inputs.json new file mode 100644 index 00000000000..cd7e00816d1 --- /dev/null +++ b/environments/action-hooks/amplify/backend/auth/actionhooks71568983/cli-inputs.json @@ -0,0 +1,48 @@ +{ + "version": "1", + "cognitoConfig": { + "identityPoolName": "actionhooks71568983_identitypool_71568983", + "allowUnauthenticatedIdentities": false, + "resourceNameTruncated": "action71568983", + "userPoolName": "actionhooks71568983_userpool_71568983", + "autoVerifiedAttributes": [ + "email" + ], + "mfaConfiguration": "OFF", + "mfaTypes": [ + "SMS Text Message" + ], + "smsAuthenticationMessage": "Your authentication code is {####}", + "smsVerificationMessage": "Your verification code is {####}", + "emailVerificationSubject": "Your verification code", + "emailVerificationMessage": "Your verification code is {####}", + "defaultPasswordPolicy": false, + "passwordPolicyMinLength": 8, + "passwordPolicyCharacters": [], + "requiredAttributes": [ + "email" + ], + "aliasAttributes": [], + "userpoolClientGenerateSecret": false, + "userpoolClientRefreshTokenValidity": 30, + "userpoolClientWriteAttributes": [ + "email" + ], + "userpoolClientReadAttributes": [ + "email" + ], + "userpoolClientLambdaRole": "action71568983_userpoolclient_lambda_role", + "userpoolClientSetAttributes": false, + "sharedId": "71568983", + "resourceName": "actionhooks71568983", + "authSelections": "identityPoolAndUserPool", + "useDefault": "default", + "usernameAttributes": [ + "email" + ], + "userPoolGroupList": [], + "serviceName": "Cognito", + "usernameCaseSensitive": false, + "useEnabledMfas": true + } +} \ No newline at end of file diff --git a/environments/action-hooks/amplify/backend/backend-config.json b/environments/action-hooks/amplify/backend/backend-config.json new file mode 100644 index 00000000000..52bc9539085 --- /dev/null +++ b/environments/action-hooks/amplify/backend/backend-config.json @@ -0,0 +1,31 @@ +{ + "auth": { + "actionhooks71568983": { + "service": "Cognito", + "providerPlugin": "awscloudformation", + "dependsOn": [], + "customAuth": false, + "frontendAuthConfig": { + "socialProviders": [], + "usernameAttributes": [ + "EMAIL" + ], + "signupAttributes": [ + "EMAIL" + ], + "passwordProtectionSettings": { + "passwordPolicyMinLength": 8, + "passwordPolicyCharacters": [] + }, + "mfaConfiguration": "OFF", + "mfaTypes": [ + "SMS" + ], + "verificationMechanisms": [ + "EMAIL" + ] + } + } + }, + "api": {} +} \ No newline at end of file diff --git a/environments/action-hooks/amplify/backend/tags.json b/environments/action-hooks/amplify/backend/tags.json new file mode 100644 index 00000000000..b9321d71b83 --- /dev/null +++ b/environments/action-hooks/amplify/backend/tags.json @@ -0,0 +1,10 @@ +[ + { + "Key": "user:Stack", + "Value": "{project-env}" + }, + { + "Key": "user:Application", + "Value": "{project-name}" + } +] \ No newline at end of file diff --git a/environments/action-hooks/amplify/backend/types/amplify-dependent-resources-ref.d.ts b/environments/action-hooks/amplify/backend/types/amplify-dependent-resources-ref.d.ts new file mode 100644 index 00000000000..f2e7bc7d7ce --- /dev/null +++ b/environments/action-hooks/amplify/backend/types/amplify-dependent-resources-ref.d.ts @@ -0,0 +1,13 @@ +export type AmplifyDependentResourcesAttributes = { + auth: { + actionhooks71568983: { + IdentityPoolId: 'string'; + IdentityPoolName: 'string'; + UserPoolId: 'string'; + UserPoolArn: 'string'; + UserPoolName: 'string'; + AppClientIDWeb: 'string'; + AppClientID: 'string'; + }; + }; +}; diff --git a/environments/action-hooks/amplify/cli.json b/environments/action-hooks/amplify/cli.json new file mode 100644 index 00000000000..507bd57ac4e --- /dev/null +++ b/environments/action-hooks/amplify/cli.json @@ -0,0 +1,53 @@ +{ + "features": { + "graphqltransformer": { + "addmissingownerfields": true, + "improvepluralization": false, + "validatetypenamereservedwords": true, + "useexperimentalpipelinedtransformer": true, + "enableiterativegsiupdates": true, + "secondarykeyasgsi": true, + "skipoverridemutationinputtypes": true, + "transformerversion": 2, + "suppressschemamigrationprompt": true, + "securityenhancementnotification": false, + "showfieldauthnotification": false + }, + "frontend-ios": { + "enablexcodeintegration": true + }, + "auth": { + "enablecaseinsensitivity": true, + "useinclusiveterminology": true, + "breakcirculardependency": true, + "forcealiasattributes": false, + "useenabledmfas": true + }, + "codegen": { + "useappsyncmodelgenplugin": true, + "usedocsgeneratorplugin": true, + "usetypesgeneratorplugin": true, + "cleangeneratedmodelsdirectory": true, + "retaincasestyle": true, + "addtimestampfields": true, + "handlelistnullabilitytransparently": true, + "emitauthprovider": true, + "generateindexrules": true, + "enabledartnullsafety": true + }, + "appsync": { + "generategraphqlpermissions": true + }, + "latestregionsupport": { + "pinpoint": 1, + "translate": 1, + "transcribe": 1, + "rekognition": 1, + "textract": 1, + "comprehend": 1 + }, + "project": { + "overrides": true + } + } +} \ No newline at end of file diff --git a/environments/action-hooks/package.json b/environments/action-hooks/package.json new file mode 100644 index 00000000000..51cd3efa684 --- /dev/null +++ b/environments/action-hooks/package.json @@ -0,0 +1,8 @@ +{ + "private": true, + "name": "action-hooks", + "version": "0.0.1", + "scripts": { + "pull": "amplify pull --appId d1r9l08y32q5fb --envName dev" + } +} diff --git a/examples/next/pages/ui/hooks/actions/AuthActions.tsx b/examples/next/pages/ui/hooks/actions/AuthActions.tsx new file mode 100644 index 00000000000..c9f75aac4d8 --- /dev/null +++ b/examples/next/pages/ui/hooks/actions/AuthActions.tsx @@ -0,0 +1,7 @@ +import { Button } from '@aws-amplify/ui-react'; +import { useAuthSignOutAction } from '@aws-amplify/ui-react/internal'; + +export const AuthSignOutButton = ({ children }) => { + const authSignOutAction = useAuthSignOutAction({ global: true }); + return ; +}; diff --git a/examples/next/pages/ui/hooks/actions/DataStoreActions.tsx b/examples/next/pages/ui/hooks/actions/DataStoreActions.tsx new file mode 100644 index 00000000000..4cc30912139 --- /dev/null +++ b/examples/next/pages/ui/hooks/actions/DataStoreActions.tsx @@ -0,0 +1,119 @@ +import * as React from 'react'; + +import { + TextField, + Button, + Collection, + Flex, + View, +} from '@aws-amplify/ui-react'; +import { + useDataStoreCollection, + useDataStoreCreateAction, + useDataStoreDeleteAction, + useDataStoreUpdateAction, +} from '@aws-amplify/ui-react/internal'; + +import { Todo } from './models'; + +export const DataStoreTodoForm = () => { + const [toDoName, setToDoName] = React.useState(''); + + const createTodoAction = useDataStoreCreateAction({ + model: Todo, + fields: { name: toDoName }, + }); + const todos = useDataStoreCollection({ model: Todo }); + + const onInput = (e: any) => { + const { value } = e.target; + setToDoName(value); + }; + + return ( + +

Shopping list:

+ + + + {(todo, key) => } + +
+ ); +}; + +const TodoItem = ({ todo }: { todo: Todo }) => { + const [showEdit, setShowEdit] = React.useState(false); + const [todoName, setToDoName] = React.useState(todo.name); + + const toggleEdit = () => { + setShowEdit(!showEdit); + }; + + const deleteTodoAction = useDataStoreDeleteAction({ + model: Todo, + id: todo.id, + }); + + const updateTodoAction = useDataStoreUpdateAction({ + model: Todo, + id: todo.id, + fields: { name: todoName }, + }); + + return ( + + {showEdit ? ( + + { + setToDoName(e.target.value); + }} + /> + + + + ) : ( + + )} + + + ); +}; diff --git a/examples/next/pages/ui/hooks/actions/NavigateActions.tsx b/examples/next/pages/ui/hooks/actions/NavigateActions.tsx new file mode 100644 index 00000000000..2f870d0e072 --- /dev/null +++ b/examples/next/pages/ui/hooks/actions/NavigateActions.tsx @@ -0,0 +1,35 @@ +import { Button, View } from '@aws-amplify/ui-react'; +import { useNavigateAction } from '@aws-amplify/ui-react/internal'; + +export const NavigateActions = () => { + const goToAmazon = useNavigateAction({ + type: 'url', + url: '/ui/hooks/actions?pageChange', + }); + const reload = useNavigateAction({ + type: 'reload', + }); + const goToHash = useNavigateAction({ + type: 'anchor', + anchor: '#notes', + }); + + const handleHashClick = () => { + console.log('Run handleHasClick'); + goToHash(); + }; + return ( + + + + + notes + + ); +}; diff --git a/examples/next/pages/ui/hooks/actions/aws-exports.js b/examples/next/pages/ui/hooks/actions/aws-exports.js new file mode 100644 index 00000000000..7a32840cb2c --- /dev/null +++ b/examples/next/pages/ui/hooks/actions/aws-exports.js @@ -0,0 +1,2 @@ +import awsExports from '@environments/action-hooks/src/aws-exports'; +export default awsExports; diff --git a/examples/next/pages/ui/hooks/actions/index.page.tsx b/examples/next/pages/ui/hooks/actions/index.page.tsx new file mode 100644 index 00000000000..957e3862d4c --- /dev/null +++ b/examples/next/pages/ui/hooks/actions/index.page.tsx @@ -0,0 +1,41 @@ +import { Amplify, Hub } from 'aws-amplify'; +import { Authenticator } from '@aws-amplify/ui-react'; +import '@aws-amplify/ui-react/styles.css'; + +import { NavigateActions } from './NavigateActions'; +import { AuthSignOutButton } from './AuthActions'; + +import awsExports from './aws-exports'; +import { useEffect } from 'react'; +import { DataStoreTodoForm } from './DataStoreActions'; +Amplify.configure({ + ...awsExports, + aws_appsync_graphqlEndpoint: 'https://fake-appsync-endpoint/graphql', +}); + +export default function App() { + useEffect(() => { + (window as any).LOG_LEVEL = 'DEBUG'; + Hub.listen('ui', (data) => { + console.log(data); + }); + Hub.listen('datastore', (data) => { + console.log(data); + }); + }, []); + + return ( +
+ + + {({ user }) => ( +
+

Hello {user.username}

+ Sign out + +
+ )} +
+
+ ); +} diff --git a/examples/next/pages/ui/hooks/actions/models/index.d.ts b/examples/next/pages/ui/hooks/actions/models/index.d.ts new file mode 100644 index 00000000000..541e84c400b --- /dev/null +++ b/examples/next/pages/ui/hooks/actions/models/index.d.ts @@ -0,0 +1,24 @@ +import { + ModelInit, + MutableModel, + PersistentModelConstructor, +} from '@aws-amplify/datastore'; + +type TodoMetaData = { + readOnlyFields: 'createdAt' | 'updatedAt'; +}; + +export declare class Todo { + readonly id: string; + readonly name: string; + readonly description?: string; + readonly createdAt?: string; + readonly updatedAt?: string; + constructor(init: ModelInit); + static copyOf( + source: Todo, + mutator: ( + draft: MutableModel + ) => MutableModel | void + ): Todo; +} diff --git a/examples/next/pages/ui/hooks/actions/models/index.js b/examples/next/pages/ui/hooks/actions/models/index.js new file mode 100644 index 00000000000..05ad5cef75e --- /dev/null +++ b/examples/next/pages/ui/hooks/actions/models/index.js @@ -0,0 +1,7 @@ +// @ts-check +import { initSchema } from '@aws-amplify/datastore'; +import { schema } from './schema'; + +const { Todo } = initSchema(schema); + +export { Todo }; diff --git a/examples/next/pages/ui/hooks/actions/models/schema.d.ts b/examples/next/pages/ui/hooks/actions/models/schema.d.ts new file mode 100644 index 00000000000..11403d537a0 --- /dev/null +++ b/examples/next/pages/ui/hooks/actions/models/schema.d.ts @@ -0,0 +1,3 @@ +import { Schema } from '@aws-amplify/datastore'; + +export declare const schema: Schema; diff --git a/examples/next/pages/ui/hooks/actions/models/schema.js b/examples/next/pages/ui/hooks/actions/models/schema.js new file mode 100644 index 00000000000..3fc6fa678b7 --- /dev/null +++ b/examples/next/pages/ui/hooks/actions/models/schema.js @@ -0,0 +1,57 @@ +export const schema = { + models: { + Todo: { + name: 'Todo', + fields: { + id: { + name: 'id', + isArray: false, + type: 'ID', + isRequired: true, + attributes: [], + }, + name: { + name: 'name', + isArray: false, + type: 'String', + isRequired: true, + attributes: [], + }, + description: { + name: 'description', + isArray: false, + type: 'String', + isRequired: false, + attributes: [], + }, + createdAt: { + name: 'createdAt', + isArray: false, + type: 'AWSDateTime', + isRequired: false, + attributes: [], + isReadOnly: true, + }, + updatedAt: { + name: 'updatedAt', + isArray: false, + type: 'AWSDateTime', + isRequired: false, + attributes: [], + isReadOnly: true, + }, + }, + syncable: true, + pluralName: 'Todos', + attributes: [ + { + type: 'model', + properties: {}, + }, + ], + }, + }, + enums: {}, + nonModels: {}, + version: '4401034582a70c60713e1f7f9da3b752', +}; diff --git a/packages/e2e/cypress/integration/common/shared.ts b/packages/e2e/cypress/integration/common/shared.ts index 8aa62d37593..4e1e9ea838f 100644 --- a/packages/e2e/cypress/integration/common/shared.ts +++ b/packages/e2e/cypress/integration/common/shared.ts @@ -2,7 +2,7 @@ /// /// -import { Given, Then, When } from 'cypress-cucumber-preprocessor/steps'; +import { And, Given, Then, When } from 'cypress-cucumber-preprocessor/steps'; import { get, escapeRegExp } from 'lodash'; let language = 'en-US'; @@ -96,12 +96,11 @@ When('I type a new {string}', (field: string) => { cy.findInputField(field).typeAliasWithStatus(field, `${Date.now()}`); }); -When( - 'I type a new {string} with value {string}', - (field: string, value: string) => { - cy.findInputField(field).type(value); - } -); +const typeInInputHandler = (field: string, value: string) => { + cy.findInputField(field).type(value); +}; +When('I type a new {string} with value {string}', typeInInputHandler); +And('I type a new {string} with value {string}', typeInInputHandler); When('I click the {string} tab', (label: string) => { cy.findByRole('tab', { @@ -115,6 +114,12 @@ When('I click the {string} button', (name: string) => { }).click(); }); +Then('I see the {string} button', (name: string) => { + cy.findByRole('button', { + name: new RegExp(`^${escapeRegExp(name)}$`, 'i'), + }).should('be.visible'); +}); + When('I click the {string} checkbox', (label: string) => { cy.findByLabelText(new RegExp(`^${escapeRegExp(label)}`, 'i')).click({ // We have to force this click because the checkbox button isn't visible by default @@ -143,6 +148,10 @@ When('I reload the page', () => { cy.reload(); }); +Then('I see tab {string}', (search: string) => { + cy.findAllByRole('tab').first().should('be.visible').contains(search); +}); + Then('I see {string}', (message: string) => { cy.findByRole('document').contains(new RegExp(escapeRegExp(message), 'i')); }); diff --git a/packages/e2e/cypress/integration/ui/hooks/actions-navigation/actions-navigation.steps.ts b/packages/e2e/cypress/integration/ui/hooks/actions-navigation/actions-navigation.steps.ts new file mode 100644 index 00000000000..71950148c17 --- /dev/null +++ b/packages/e2e/cypress/integration/ui/hooks/actions-navigation/actions-navigation.steps.ts @@ -0,0 +1,19 @@ +import { Then, When } from 'cypress-cucumber-preprocessor/steps'; + +Then('the page contains {string} section', (search: string) => { + cy.findByRole('document').contains(search); +}); + +When('I click the {string} button', (testId: string) => { + cy.findByTestId(testId).click(); +}); + +Then('My url contains {string}', (pathSearch: string) => { + cy.url().should('include', pathSearch); +}); + +Then('My page should be reloaded', () => { + // performance navigation type 1 means the page has been reloaded + // https://developer.mozilla.org/en-US/docs/Web/API/PerformanceNavigation/type + cy.window().its('performance.navigation.type').should('equal', 1); +}); diff --git a/packages/e2e/features/ui/hooks/actions-auth.feature b/packages/e2e/features/ui/hooks/actions-auth.feature new file mode 100644 index 00000000000..987e8f84fe1 --- /dev/null +++ b/packages/e2e/features/ui/hooks/actions-auth.feature @@ -0,0 +1,16 @@ +Feature: Auth action hooks + + The Auth action hooks work correctly + + Background: + Given I'm running the example "ui/hooks/actions" + Then I see tab "Sign In" + + @react + Scenario: Signout action works correctly + When I type my "email" with status "CONFIRMED" + And I type my password + And I click the "Sign in" button + Then I see "Sign out" + And I click the "Sign out" button + Then I see tab "Sign In" diff --git a/packages/e2e/features/ui/hooks/actions-datastore.feature b/packages/e2e/features/ui/hooks/actions-datastore.feature new file mode 100644 index 00000000000..9b8b1eeb7b1 --- /dev/null +++ b/packages/e2e/features/ui/hooks/actions-datastore.feature @@ -0,0 +1,28 @@ +Feature: Datastore action hooks + + The Datastore action hooks work correctly + + Background: + Given I'm running the example "ui/hooks/actions" + When I type my "email" with status "CONFIRMED" + And I type my password + And I click the "Sign in" button + Then I see "Sign out" + + @react + Scenario: DataStore Create, Update, Delete actions work correctly + # Create + When I type a new "ToDo" with value "milk2" + And I click the "Save" button + Then I see the "milk2" button + + # Update + When I click the "milk2" button + And I click the "clear" button + And I type a new "Update todo" with value "eggs" + And I click the "Update" button + Then I see the "eggs" button + + # Delete + When I click the "Delete" button + Then I don't see "eggs" diff --git a/packages/e2e/features/ui/hooks/actions-navigation.feature b/packages/e2e/features/ui/hooks/actions-navigation.feature new file mode 100644 index 00000000000..c28923f75f6 --- /dev/null +++ b/packages/e2e/features/ui/hooks/actions-navigation.feature @@ -0,0 +1,22 @@ +Feature: Navigate action hooks + + The Navigate action hooks work correctly + + Background: + Given I'm running the example "ui/hooks/actions" + Then the page contains "Sign In" section + + @react + Scenario: Navigation anchor action works correctly + When I click the "hash" button + Then My url contains "#notes" + + @react + Scenario: Navigation reload action works correctly + When I click the "reload" button + Then My page should be reloaded + + @react + Scenario: Navigation location change action works correctly + When I click the "locationChange" button + Then My url contains "ui/hooks/actions?pageChange" diff --git a/packages/e2e/features/ui/hooks/hooks.features b/packages/e2e/features/ui/hooks/hooks.features new file mode 100644 index 00000000000..776e020cc6c --- /dev/null +++ b/packages/e2e/features/ui/hooks/hooks.features @@ -0,0 +1,2 @@ +# This file bundles automatically bundles all `.feature` files +# See: https://github.com/TheBrainFamily/cypress-cucumber-preprocessor#bundled-features-files diff --git a/packages/e2e/package.json b/packages/e2e/package.json index 80ed9b4ed04..dc038356a77 100644 --- a/packages/e2e/package.json +++ b/packages/e2e/package.json @@ -6,8 +6,9 @@ "scripts": { "clean": "rimraf node_modules", "dev": "TZ=UTC cypress open", - "test": "TZ=UTC cypress run --spec features/ui/components/authenticator/*.features", + "test:examples": "TZ=UTC cypress run --spec features/ui/hooks/*.features,features/ui/components/authenticator/*.features", "test:authenticator": "TZ=UTC cypress run --spec features/ui/components/authenticator/*.features", + "test:hooks": "TZ=UTC cypress run --spec features/ui/hooks/*.features", "test:theme": "TZ=UTC cypress run --spec features/ui/theme/*.features" }, "cypress-cucumber-preprocessor": { diff --git a/packages/react/__tests__/exports.ts b/packages/react/__tests__/exports.ts index 5e842d6d3a6..bad88b2d9ed 100644 --- a/packages/react/__tests__/exports.ts +++ b/packages/react/__tests__/exports.ts @@ -1445,9 +1445,15 @@ describe('@aws-amplify/ui-react/internal', () => { "getOverridesFromVariants", "mergeVariantsAndOverrides", "useAuth", + "useAuthSignOutAction", "useDataStoreBinding", "useDataStoreCollection", + "useDataStoreCreateAction", + "useDataStoreDeleteAction", "useDataStoreItem", + "useDataStoreUpdateAction", + "useNavigateAction", + "useStateMutationAction", "useStorageURL", ] `); diff --git a/packages/react/src/helpers/constants.ts b/packages/react/src/helpers/constants.ts new file mode 100644 index 00000000000..529fa388e52 --- /dev/null +++ b/packages/react/src/helpers/constants.ts @@ -0,0 +1,4 @@ +export const AMPLIFY_SYMBOL = (typeof Symbol !== 'undefined' && +typeof Symbol.for === 'function' + ? Symbol.for('amplify_default') + : '@@amplify_default') as Symbol; diff --git a/packages/react/src/helpers/utils.ts b/packages/react/src/helpers/utils.ts index 90e89203f63..fb4fd88b232 100644 --- a/packages/react/src/helpers/utils.ts +++ b/packages/react/src/helpers/utils.ts @@ -20,3 +20,36 @@ export const areArraysEqual = (arr1: Array, arr2: Array) => { if (arr1.length !== arr2.length) return false; return arr1.every((value, index) => value === arr2[index]); }; + +// Error message handling source: +// https://kentcdodds.com/blog/get-a-catch-block-error-message-with-typescript +type ErrorWithMessage = { + message: string; +}; + +export const isErrorWithMessage = ( + error: unknown +): error is ErrorWithMessage => { + return ( + typeof error === 'object' && + error !== null && + 'message' in error && + typeof (error as Record).message === 'string' + ); +}; + +export const toErrorWithMessage = (maybeError: unknown): ErrorWithMessage => { + if (isErrorWithMessage(maybeError)) return maybeError; + + try { + return new Error(JSON.stringify(maybeError)); + } catch { + // fallback in case there's an error stringifying the maybeError + // like with circular references for example. + return new Error(String(maybeError)); + } +}; + +export const getErrorMessage = (error: unknown) => { + return toErrorWithMessage(error).message; +}; diff --git a/packages/react/src/hooks/actions/__tests__/useAuthSignOutAction.test.ts b/packages/react/src/hooks/actions/__tests__/useAuthSignOutAction.test.ts new file mode 100644 index 00000000000..080e3a3f39a --- /dev/null +++ b/packages/react/src/hooks/actions/__tests__/useAuthSignOutAction.test.ts @@ -0,0 +1,76 @@ +import { useAuthSignOutAction } from '../useAuthSignOutAction'; + +import { Auth, Hub } from 'aws-amplify'; +import { + ACTION_AUTH_SIGNOUT_FINISHED, + ACTION_AUTH_SIGNOUT_STARTED, + EVENT_ACTION_AUTH_SIGNOUT, + UI_CHANNEL, +} from '../constants'; +import { AMPLIFY_SYMBOL } from '../../../helpers/constants'; + +jest.mock('aws-amplify'); + +const signOutSpy = jest.spyOn(Auth, 'signOut'); +const hubDispatchSpy = jest.spyOn(Hub, 'dispatch'); + +describe('useAuthSignOutAction', () => { + beforeEach(() => jest.clearAllMocks()); + + it('should call Auth.SignOut', async () => { + const authSignOutAction = useAuthSignOutAction(); + + await authSignOutAction(); + expect(signOutSpy).toHaveBeenCalledTimes(1); + }); + + it('should call Hub with started and finished events', async () => { + const authSignOutAction = useAuthSignOutAction(); + + await authSignOutAction(); + expect(hubDispatchSpy).toHaveBeenCalledTimes(2); + expect(hubDispatchSpy).toHaveBeenCalledWith( + UI_CHANNEL, + { + data: { options: undefined }, + event: ACTION_AUTH_SIGNOUT_STARTED, + }, + EVENT_ACTION_AUTH_SIGNOUT, + AMPLIFY_SYMBOL + ); + expect(hubDispatchSpy).toHaveBeenCalledWith( + UI_CHANNEL, + { + data: { options: undefined }, + event: ACTION_AUTH_SIGNOUT_FINISHED, + }, + EVENT_ACTION_AUTH_SIGNOUT, + AMPLIFY_SYMBOL + ); + }); + + it('should call Hub with started and finished events with options', async () => { + const authSignOutAction = useAuthSignOutAction({ global: true }); + + await authSignOutAction(); + expect(hubDispatchSpy).toHaveBeenCalledTimes(2); + expect(hubDispatchSpy).toHaveBeenCalledWith( + UI_CHANNEL, + { + data: { options: { global: true } }, + event: ACTION_AUTH_SIGNOUT_STARTED, + }, + EVENT_ACTION_AUTH_SIGNOUT, + AMPLIFY_SYMBOL + ); + expect(hubDispatchSpy).toHaveBeenCalledWith( + UI_CHANNEL, + { + data: { options: { global: true } }, + event: ACTION_AUTH_SIGNOUT_FINISHED, + }, + EVENT_ACTION_AUTH_SIGNOUT, + AMPLIFY_SYMBOL + ); + }); +}); diff --git a/packages/react/src/hooks/actions/__tests__/useDataStoreCreate.test.ts b/packages/react/src/hooks/actions/__tests__/useDataStoreCreate.test.ts new file mode 100644 index 00000000000..c4cce4ecef5 --- /dev/null +++ b/packages/react/src/hooks/actions/__tests__/useDataStoreCreate.test.ts @@ -0,0 +1,89 @@ +import { DataStore, Hub } from 'aws-amplify'; + +import { + ACTION_DATASTORE_CREATE_FINISHED, + ACTION_DATASTORE_CREATE_STARTED, + EVENT_ACTION_DATASTORE_CREATE, + UI_CHANNEL, +} from '../constants'; +import { Todo } from '../testShared'; +import { useDataStoreCreateAction } from '../useDataStoreCreateAction'; +import { AMPLIFY_SYMBOL } from '../../../helpers/constants'; + +jest.mock('aws-amplify'); + +const saveSpy = jest.spyOn(DataStore, 'save'); +const hubDispatchSpy = jest.spyOn(Hub, 'dispatch'); + +const name = 'milk'; +const dataStoreCreateArgs = { + model: Todo, + fields: { name: name }, +}; +describe('useDataStoreCreateAction', () => { + beforeEach(() => jest.clearAllMocks()); + + it('should call DataStore.save', async () => { + const action = useDataStoreCreateAction(dataStoreCreateArgs); + + await action(); + expect(saveSpy).toHaveBeenCalledTimes(1); + }); + + it('should call Hub with started and finished events', async () => { + const action = useDataStoreCreateAction(dataStoreCreateArgs); + + await action(); + expect(hubDispatchSpy).toHaveBeenCalledTimes(2); + expect(hubDispatchSpy).toHaveBeenCalledWith( + UI_CHANNEL, + { + data: { fields: { name } }, + event: ACTION_DATASTORE_CREATE_STARTED, + }, + EVENT_ACTION_DATASTORE_CREATE, + AMPLIFY_SYMBOL + ); + expect(hubDispatchSpy).toHaveBeenCalledWith( + UI_CHANNEL, + { + data: { fields: { name } }, + event: ACTION_DATASTORE_CREATE_FINISHED, + }, + EVENT_ACTION_DATASTORE_CREATE, + AMPLIFY_SYMBOL + ); + }); + + it('should call Hub with error message if DataStore.save rejects', async () => { + const errorMessage = 'Invalid data model'; + saveSpy.mockImplementation(() => Promise.reject(new Error(errorMessage))); + + const action = useDataStoreCreateAction(dataStoreCreateArgs); + + await action(); + + expect(hubDispatchSpy).toHaveBeenCalledTimes(2); + expect(hubDispatchSpy).toHaveBeenCalledWith( + UI_CHANNEL, + { + data: { fields: { name } }, + event: ACTION_DATASTORE_CREATE_STARTED, + }, + EVENT_ACTION_DATASTORE_CREATE, + AMPLIFY_SYMBOL + ); + expect(hubDispatchSpy).toHaveBeenCalledWith( + UI_CHANNEL, + { + data: { + fields: { name }, + errorMessage, + }, + event: ACTION_DATASTORE_CREATE_FINISHED, + }, + EVENT_ACTION_DATASTORE_CREATE, + AMPLIFY_SYMBOL + ); + }); +}); diff --git a/packages/react/src/hooks/actions/__tests__/useDataStoreDelete.test.ts b/packages/react/src/hooks/actions/__tests__/useDataStoreDelete.test.ts new file mode 100644 index 00000000000..3e604566563 --- /dev/null +++ b/packages/react/src/hooks/actions/__tests__/useDataStoreDelete.test.ts @@ -0,0 +1,90 @@ +import { DataStore, Hub } from 'aws-amplify'; + +import { + ACTION_DATASTORE_DELETE_FINISHED, + ACTION_DATASTORE_DELETE_STARTED, + EVENT_ACTION_DATASTORE_DELETE, + UI_CHANNEL, +} from '../constants'; +import { Todo } from '../testShared'; +import { useDataStoreDeleteAction } from '../useDataStoreDeleteAction'; +import { AMPLIFY_SYMBOL } from '../../../helpers/constants'; + +jest.mock('aws-amplify'); + +const deleteSpy = jest.spyOn(DataStore, 'delete'); +const hubDispatchSpy = jest.spyOn(Hub, 'dispatch'); + +const id = '1234'; +const dataStoreDeleteArgs = { + model: Todo, + id, +}; + +describe('useDataStoreDeleteAction', () => { + beforeEach(() => jest.clearAllMocks()); + + it('should call DataStore.delete', async () => { + const action = useDataStoreDeleteAction(dataStoreDeleteArgs); + + await action(); + expect(deleteSpy).toHaveBeenCalledTimes(1); + }); + + it('should call Hub with started and finished events', async () => { + const action = useDataStoreDeleteAction(dataStoreDeleteArgs); + + await action(); + expect(hubDispatchSpy).toHaveBeenCalledTimes(2); + expect(hubDispatchSpy).toHaveBeenCalledWith( + UI_CHANNEL, + { + data: { id }, + event: ACTION_DATASTORE_DELETE_STARTED, + }, + EVENT_ACTION_DATASTORE_DELETE, + AMPLIFY_SYMBOL + ); + expect(hubDispatchSpy).toHaveBeenCalledWith( + UI_CHANNEL, + { + data: { id }, + event: ACTION_DATASTORE_DELETE_FINISHED, + }, + EVENT_ACTION_DATASTORE_DELETE, + AMPLIFY_SYMBOL + ); + }); + + it('should call Hub with error message if DataStore.delete rejects', async () => { + const errorMessage = 'Invalid data model'; + deleteSpy.mockImplementation(() => Promise.reject(new Error(errorMessage))); + + const action = useDataStoreDeleteAction(dataStoreDeleteArgs); + + await action(); + + expect(hubDispatchSpy).toHaveBeenCalledTimes(2); + expect(hubDispatchSpy).toHaveBeenCalledWith( + UI_CHANNEL, + { + data: { id }, + event: ACTION_DATASTORE_DELETE_STARTED, + }, + EVENT_ACTION_DATASTORE_DELETE, + AMPLIFY_SYMBOL + ); + expect(hubDispatchSpy).toHaveBeenCalledWith( + UI_CHANNEL, + { + data: { + id, + errorMessage, + }, + event: ACTION_DATASTORE_DELETE_FINISHED, + }, + EVENT_ACTION_DATASTORE_DELETE, + AMPLIFY_SYMBOL + ); + }); +}); diff --git a/packages/react/src/hooks/actions/__tests__/useDataStoreUpdate.test.ts b/packages/react/src/hooks/actions/__tests__/useDataStoreUpdate.test.ts new file mode 100644 index 00000000000..a3283e94b5f --- /dev/null +++ b/packages/react/src/hooks/actions/__tests__/useDataStoreUpdate.test.ts @@ -0,0 +1,128 @@ +import { DataStore, Hub } from 'aws-amplify'; + +import { + ACTION_DATASTORE_UPDATE_FINISHED, + ACTION_DATASTORE_UPDATE_STARTED, + EVENT_ACTION_DATASTORE_UPDATE, + UI_CHANNEL, +} from '../constants'; +import { Todo } from '../testShared'; +import { useDataStoreUpdateAction } from '../useDataStoreUpdateAction'; +import { AMPLIFY_SYMBOL } from '../../../helpers/constants'; + +jest.mock('aws-amplify'); +const name = 'milk'; +const id = '1234'; +const updateActionArgs = { + model: Todo, + id, + fields: { name }, +}; + +const saveSpy = jest.spyOn(DataStore, 'save'); +const querySpy = jest + .spyOn(DataStore, 'query') + .mockImplementation(() => Promise.resolve([{ id, name }])); +const hubDispatchSpy = jest.spyOn(Hub, 'dispatch'); + +describe('useAuthSignOutAction', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should call DataStore.save', async () => { + const action = useDataStoreUpdateAction(updateActionArgs); + + await action(); + expect(querySpy).toHaveBeenCalledTimes(1); + expect(saveSpy).toHaveBeenCalledTimes(1); + }); + + it('should call Hub with started and finished events', async () => { + const action = useDataStoreUpdateAction(updateActionArgs); + + await action(); + expect(hubDispatchSpy).toHaveBeenCalledTimes(2); + expect(hubDispatchSpy).toHaveBeenCalledWith( + UI_CHANNEL, + { + data: { id, fields: { name } }, + event: ACTION_DATASTORE_UPDATE_STARTED, + }, + EVENT_ACTION_DATASTORE_UPDATE, + AMPLIFY_SYMBOL + ); + expect(hubDispatchSpy).toHaveBeenCalledWith( + UI_CHANNEL, + { + data: { id, fields: { name } }, + event: ACTION_DATASTORE_UPDATE_FINISHED, + }, + EVENT_ACTION_DATASTORE_UPDATE, + AMPLIFY_SYMBOL + ); + }); + + it('should call Hub with error message if DataStore.save rejects', async () => { + const errorMessage = 'Invalid data model'; + saveSpy.mockImplementation(() => Promise.reject(new Error(errorMessage))); + querySpy.mockImplementation(() => Promise.resolve([{ id, name }])); + const action = useDataStoreUpdateAction(updateActionArgs); + + await action(); + + expect(hubDispatchSpy).toHaveBeenCalledTimes(2); + expect(hubDispatchSpy).toHaveBeenCalledWith( + UI_CHANNEL, + { + data: { id, fields: { name } }, + event: ACTION_DATASTORE_UPDATE_STARTED, + }, + EVENT_ACTION_DATASTORE_UPDATE, + AMPLIFY_SYMBOL + ); + expect(hubDispatchSpy).toHaveBeenCalledWith( + UI_CHANNEL, + { + data: { + id, + fields: { name }, + errorMessage, + }, + event: ACTION_DATASTORE_UPDATE_FINISHED, + }, + EVENT_ACTION_DATASTORE_UPDATE, + AMPLIFY_SYMBOL + ); + }); + + it('when original not found, should call Hub with error message', async () => { + const action = useDataStoreUpdateAction(updateActionArgs); + querySpy.mockImplementationOnce(() => Promise.resolve(undefined)); + + await action(); + expect(hubDispatchSpy).toHaveBeenCalledTimes(2); + expect(hubDispatchSpy).toHaveBeenCalledWith( + UI_CHANNEL, + { + data: { id, fields: { name } }, + event: ACTION_DATASTORE_UPDATE_STARTED, + }, + EVENT_ACTION_DATASTORE_UPDATE, + AMPLIFY_SYMBOL + ); + expect(hubDispatchSpy).toHaveBeenCalledWith( + UI_CHANNEL, + { + data: { + id, + fields: { name }, + errorMessage: `Error querying datastore item by id: ${id}`, + }, + event: ACTION_DATASTORE_UPDATE_FINISHED, + }, + EVENT_ACTION_DATASTORE_UPDATE, + AMPLIFY_SYMBOL + ); + }); +}); diff --git a/packages/react/src/hooks/actions/__tests__/useNavigateAction.test.ts b/packages/react/src/hooks/actions/__tests__/useNavigateAction.test.ts new file mode 100644 index 00000000000..4fd1640a5c7 --- /dev/null +++ b/packages/react/src/hooks/actions/__tests__/useNavigateAction.test.ts @@ -0,0 +1,133 @@ +import { Hub } from 'aws-amplify'; +import { renderHook } from '@testing-library/react-hooks'; + +import { + ACTION_NAVIGATE_FINISHED, + ACTION_NAVIGATE_STARTED, + EVENT_ACTION_CORE_NAVIGATE, + UI_CHANNEL, +} from '../constants'; +import { + defaultTarget, + windowFeatures, + useNavigateAction, + UseNavigateActionOptions, +} from '../useNavigateAction'; +import { AMPLIFY_SYMBOL } from '../../../helpers/constants'; + +jest.mock('aws-amplify'); + +describe('useNavigateHook: ', () => { + let windowSpy: jest.SpyInstance; + const url = 'https://www.amazon.com/'; + const target = '_blank'; + const anchor = '#about'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const testHubEventEmit = (data: UseNavigateActionOptions) => { + expect(Hub.dispatch).toHaveBeenCalledTimes(2); + expect(Hub.dispatch).toHaveBeenCalledWith( + UI_CHANNEL, + { + event: ACTION_NAVIGATE_STARTED, + data, + }, + EVENT_ACTION_CORE_NAVIGATE, + AMPLIFY_SYMBOL + ); + expect(Hub.dispatch).toHaveBeenLastCalledWith( + UI_CHANNEL, + { + event: ACTION_NAVIGATE_FINISHED, + data, + }, + EVENT_ACTION_CORE_NAVIGATE, + AMPLIFY_SYMBOL + ); + }; + + it('Should call window.open', () => { + windowSpy = jest.spyOn(window, 'open').mockImplementation((url, target) => { + console.log(url, target); + return window; + }); + + const config: UseNavigateActionOptions = { + type: 'url', + url, + }; + const { result } = renderHook(() => useNavigateAction(config)); + + result.current(); + expect(windowSpy).toBeCalledTimes(1); + expect(windowSpy).toBeCalledWith(url, defaultTarget, windowFeatures); + + testHubEventEmit(config); + + windowSpy.mockRestore(); + }); + + it('Should call window.open with "_blank" as target', () => { + windowSpy = jest.spyOn(window, 'open').mockImplementation((url, target) => { + console.log(url, target); + return window; + }); + + const config: UseNavigateActionOptions = { + type: 'url', + url, + target, + }; + const { result } = renderHook(() => useNavigateAction(config)); + + result.current(); + expect(windowSpy).toBeCalledTimes(1); + expect(windowSpy).toBeCalledWith(url, target, windowFeatures); + + testHubEventEmit(config); + + windowSpy.mockRestore(); + }); + + it('Should change window location hash', () => { + const config: UseNavigateActionOptions = { + type: 'anchor', + anchor, + }; + const { result } = renderHook(() => useNavigateAction(config)); + + expect(window.location.hash).toBe(''); + result.current(); + expect(window.location.hash).toBe(anchor); + + testHubEventEmit(config); + }); + + it('Should call window.reload', () => { + const location = window.location; + // reload is a read-only prop and thus cannot be simply spied on + // https://stackoverflow.com/questions/55712640/jest-testing-window-location-reload + // @ts-ignore + delete window.location; + + window.location = { + ...location, + reload: jest.fn(), + }; + + const config: UseNavigateActionOptions = { + type: 'reload', + }; + const { result } = renderHook(() => useNavigateAction(config)); + + result.current(); + expect(window.location.reload).toBeCalledTimes(1); + + testHubEventEmit(config); + + window.location = location; + }); +}); diff --git a/packages/react/src/hooks/actions/__tests__/useStateMutationAction.test.ts b/packages/react/src/hooks/actions/__tests__/useStateMutationAction.test.ts new file mode 100644 index 00000000000..b8b1291579c --- /dev/null +++ b/packages/react/src/hooks/actions/__tests__/useStateMutationAction.test.ts @@ -0,0 +1,50 @@ +import { Hub } from 'aws-amplify'; +import { renderHook, act } from '@testing-library/react-hooks'; + +import { + ACTION_STATE_MUTATION_FINISHED, + ACTION_STATE_MUTATION_STARTED, + EVENT_ACTION_CORE_STATE_MUTATION, + UI_CHANNEL, +} from '../constants'; +import { useStateMutationAction } from '../useStateMutationAction'; +import { AMPLIFY_SYMBOL } from '../../../helpers/constants'; + +jest.mock('aws-amplify'); + +describe('useStateMutationAction: ', () => { + it('should update state correctly', () => { + const prevState = 'none'; + const newState = 'block'; + const data = { prevState, newState }; + + const { result } = renderHook(() => useStateMutationAction(prevState)); + act(() => { + const [_, setNewState] = result.current; + setNewState(newState); + }); + + const [state, _] = result.current; + expect(state).toBe(newState); + + expect(Hub.dispatch).toHaveBeenCalledTimes(2); + expect(Hub.dispatch).toHaveBeenCalledWith( + UI_CHANNEL, + { + event: ACTION_STATE_MUTATION_STARTED, + data, + }, + EVENT_ACTION_CORE_STATE_MUTATION, + AMPLIFY_SYMBOL + ); + expect(Hub.dispatch).toHaveBeenLastCalledWith( + UI_CHANNEL, + { + event: ACTION_STATE_MUTATION_FINISHED, + data, + }, + EVENT_ACTION_CORE_STATE_MUTATION, + AMPLIFY_SYMBOL + ); + }); +}); diff --git a/packages/react/src/hooks/actions/constants.ts b/packages/react/src/hooks/actions/constants.ts new file mode 100644 index 00000000000..e269d0abcf1 --- /dev/null +++ b/packages/react/src/hooks/actions/constants.ts @@ -0,0 +1,52 @@ +/** + * UI Actions use the `ui` channel + * Format for `ui` channel events is EVENT_TYPE:CATEGORY:NAME:STATUS + */ +export const UI_CHANNEL = 'ui'; +export const UI_EVENT_TYPE_ACTIONS = 'actions'; +export const CATEGORY_AUTH = 'auth'; +export const CATEGORY_DATASTORE = 'datastore'; +export const CATEGORY_CORE = 'core'; +export const ACTION_AUTH_SIGNOUT = 'signout'; +export const ACTION_NAVIGATE = 'navigate'; +export const ACTION_DATASTORE_CREATE = 'create'; +export const ACTION_DATASTORE_DELETE = 'delete'; +export const ACTION_DATASTORE_UPDATE = 'update'; +export const ACTION_STATE_MUTATION = 'statemutation'; +export const STATUS_STARTED = 'started'; +export const STATUS_FINISHED = 'finished'; + +// actions:auth:signout +export const EVENT_ACTION_AUTH = `${UI_EVENT_TYPE_ACTIONS}:${CATEGORY_AUTH}`; +export const EVENT_ACTION_AUTH_SIGNOUT = `${EVENT_ACTION_AUTH}:${ACTION_AUTH_SIGNOUT}`; +export const ACTION_AUTH_SIGNOUT_STARTED = `${EVENT_ACTION_AUTH_SIGNOUT}:${STATUS_STARTED}`; +export const ACTION_AUTH_SIGNOUT_FINISHED = `${EVENT_ACTION_AUTH_SIGNOUT}:${STATUS_FINISHED}`; + +// actions:core +export const EVENT_ACTION_CORE = `${UI_EVENT_TYPE_ACTIONS}:${CATEGORY_CORE}`; +// actions:core:statemutation +export const EVENT_ACTION_CORE_STATE_MUTATION = `${EVENT_ACTION_CORE}:${ACTION_STATE_MUTATION}`; +export const ACTION_STATE_MUTATION_STARTED = `${EVENT_ACTION_CORE_STATE_MUTATION}:${STATUS_STARTED}`; +export const ACTION_STATE_MUTATION_FINISHED = `${EVENT_ACTION_CORE_STATE_MUTATION}:${STATUS_FINISHED}`; +// actions:core:navigate +export const EVENT_ACTION_CORE_NAVIGATE = `${EVENT_ACTION_CORE}:${ACTION_NAVIGATE}`; +export const ACTION_NAVIGATE_STARTED = `${EVENT_ACTION_CORE_NAVIGATE}:${STATUS_STARTED}`; +export const ACTION_NAVIGATE_FINISHED = `${EVENT_ACTION_CORE_NAVIGATE}:${STATUS_FINISHED}`; + +// actions:datastore +export const EVENT_ACTION_DATASTORE = `${UI_EVENT_TYPE_ACTIONS}:${CATEGORY_DATASTORE}`; +// actions:datastore:create +export const EVENT_ACTION_DATASTORE_CREATE = `${EVENT_ACTION_DATASTORE}:${ACTION_DATASTORE_CREATE}`; +export const ACTION_DATASTORE_CREATE_STARTED = `${EVENT_ACTION_DATASTORE_CREATE}:${STATUS_STARTED}`; +export const ACTION_DATASTORE_CREATE_FINISHED = `${EVENT_ACTION_DATASTORE_CREATE}:${STATUS_FINISHED}`; +// actions:datastore:delete +export const EVENT_ACTION_DATASTORE_DELETE = `${EVENT_ACTION_DATASTORE}:${ACTION_DATASTORE_DELETE}`; +export const ACTION_DATASTORE_DELETE_STARTED = `${EVENT_ACTION_DATASTORE_DELETE}:${STATUS_STARTED}`; +export const ACTION_DATASTORE_DELETE_FINISHED = `${EVENT_ACTION_DATASTORE_DELETE}:${STATUS_FINISHED}`; +// actions:datastore:update +export const EVENT_ACTION_DATASTORE_UPDATE = `${EVENT_ACTION_DATASTORE}:${ACTION_DATASTORE_UPDATE}`; +export const ACTION_DATASTORE_UPDATE_STARTED = `${EVENT_ACTION_DATASTORE_UPDATE}:${STATUS_STARTED}`; +export const ACTION_DATASTORE_UPDATE_FINISHED = `${EVENT_ACTION_DATASTORE_UPDATE}:${STATUS_FINISHED}`; + +export const DATASTORE_QUERY_BY_ID_ERROR = + 'Error querying datastore item by id'; diff --git a/packages/react/src/hooks/actions/testShared.ts b/packages/react/src/hooks/actions/testShared.ts new file mode 100644 index 00000000000..5685683608a --- /dev/null +++ b/packages/react/src/hooks/actions/testShared.ts @@ -0,0 +1,26 @@ +import type { ModelInit, MutableModel } from '@aws-amplify/datastore'; + +export type TodoMetaData = { + readOnlyFields: 'createdAt' | 'updatedAt'; +}; + +export class Todo { + readonly id: string; + readonly name: string; + readonly description?: string; + readonly createdAt?: string; + readonly updatedAt?: string; + constructor(init: ModelInit) { + this.name = init.name; + } + static copyOf( + source: Todo, + mutator: ( + draft: MutableModel + ) => MutableModel | void + ): Todo { + const copy = { ...source }; + mutator(copy); + return copy; + } +} diff --git a/packages/react/src/hooks/actions/useAuthSignOutAction.ts b/packages/react/src/hooks/actions/useAuthSignOutAction.ts new file mode 100644 index 00000000000..4df6742ff2a --- /dev/null +++ b/packages/react/src/hooks/actions/useAuthSignOutAction.ts @@ -0,0 +1,56 @@ +import { Auth, Hub } from 'aws-amplify'; +import { SignOutOpts } from '@aws-amplify/auth/lib-esm/types/Auth'; + +import { + UI_CHANNEL, + ACTION_AUTH_SIGNOUT_FINISHED, + ACTION_AUTH_SIGNOUT_STARTED, + EVENT_ACTION_AUTH_SIGNOUT, +} from './constants'; +import { getErrorMessage } from '../../helpers/utils'; +import { AMPLIFY_SYMBOL } from '../../helpers/constants'; + +export interface UseAuthSignOutAction { + (options?: SignOutOpts): () => Promise; +} + +/** + * Action to Signout of Authenticated session + * @internal + */ +export const useAuthSignOutAction: UseAuthSignOutAction = ( + options +) => async () => { + try { + Hub.dispatch( + UI_CHANNEL, + { + event: ACTION_AUTH_SIGNOUT_STARTED, + data: { options }, + }, + EVENT_ACTION_AUTH_SIGNOUT, + AMPLIFY_SYMBOL + ); + + await Auth.signOut(options); + Hub.dispatch( + UI_CHANNEL, + { + event: ACTION_AUTH_SIGNOUT_FINISHED, + data: { options }, + }, + EVENT_ACTION_AUTH_SIGNOUT, + AMPLIFY_SYMBOL + ); + } catch (error) { + Hub.dispatch( + UI_CHANNEL, + { + event: ACTION_AUTH_SIGNOUT_FINISHED, + data: { options, errorMessage: getErrorMessage(error) }, + }, + EVENT_ACTION_AUTH_SIGNOUT, + AMPLIFY_SYMBOL + ); + } +}; diff --git a/packages/react/src/hooks/actions/useDataStoreCreateAction.ts b/packages/react/src/hooks/actions/useDataStoreCreateAction.ts new file mode 100644 index 00000000000..b6f9f9ce163 --- /dev/null +++ b/packages/react/src/hooks/actions/useDataStoreCreateAction.ts @@ -0,0 +1,65 @@ +import { + ModelInit, + PersistentModel, + PersistentModelConstructor, +} from '@aws-amplify/datastore'; +import { DataStore, Hub } from 'aws-amplify'; + +import { + ACTION_DATASTORE_CREATE_FINISHED, + ACTION_DATASTORE_CREATE_STARTED, + EVENT_ACTION_DATASTORE_CREATE, + UI_CHANNEL, +} from './constants'; +import { getErrorMessage } from '../../helpers/utils'; +import { AMPLIFY_SYMBOL } from '../../helpers/constants'; + +export interface UseDataStoreCreateActionOptions< + Model extends PersistentModel +> { + model: PersistentModelConstructor; + fields: ModelInit; +} + +/** + * Action to Create DataStore item + * @internal + */ +export const useDataStoreCreateAction = ({ + model, + fields, +}: UseDataStoreCreateActionOptions) => async () => { + try { + Hub.dispatch( + UI_CHANNEL, + { + event: ACTION_DATASTORE_CREATE_STARTED, + data: { fields }, + }, + EVENT_ACTION_DATASTORE_CREATE, + AMPLIFY_SYMBOL + ); + + const item = await DataStore.save(new model(fields)); + + Hub.dispatch( + UI_CHANNEL, + { + event: ACTION_DATASTORE_CREATE_FINISHED, + data: { fields, item }, + }, + EVENT_ACTION_DATASTORE_CREATE, + AMPLIFY_SYMBOL + ); + } catch (error) { + Hub.dispatch( + UI_CHANNEL, + { + event: ACTION_DATASTORE_CREATE_FINISHED, + data: { fields, errorMessage: getErrorMessage(error) }, + }, + EVENT_ACTION_DATASTORE_CREATE, + AMPLIFY_SYMBOL + ); + } +}; diff --git a/packages/react/src/hooks/actions/useDataStoreDeleteAction.ts b/packages/react/src/hooks/actions/useDataStoreDeleteAction.ts new file mode 100644 index 00000000000..2e26fe95475 --- /dev/null +++ b/packages/react/src/hooks/actions/useDataStoreDeleteAction.ts @@ -0,0 +1,64 @@ +import { + PersistentModel, + PersistentModelConstructor, +} from '@aws-amplify/datastore'; +import { DataStore, Hub } from 'aws-amplify'; + +import { + ACTION_DATASTORE_DELETE_FINISHED, + ACTION_DATASTORE_DELETE_STARTED, + EVENT_ACTION_DATASTORE_DELETE, + UI_CHANNEL, +} from './constants'; +import { getErrorMessage } from '../../helpers/utils'; +import { AMPLIFY_SYMBOL } from '../../helpers/constants'; + +export interface UseDataStoreDeleteActionOptions< + Model extends PersistentModel +> { + model: PersistentModelConstructor; + id: string; +} + +/** + * Action to Delete DataStore item + * @internal + */ +export const useDataStoreDeleteAction = ({ + model, + id, +}: UseDataStoreDeleteActionOptions) => async () => { + try { + Hub.dispatch( + UI_CHANNEL, + { + event: ACTION_DATASTORE_DELETE_STARTED, + data: { id }, + }, + EVENT_ACTION_DATASTORE_DELETE, + AMPLIFY_SYMBOL + ); + + await DataStore.delete(model, id); + + Hub.dispatch( + UI_CHANNEL, + { + event: ACTION_DATASTORE_DELETE_FINISHED, + data: { id }, + }, + EVENT_ACTION_DATASTORE_DELETE, + AMPLIFY_SYMBOL + ); + } catch (error) { + Hub.dispatch( + UI_CHANNEL, + { + event: ACTION_DATASTORE_DELETE_FINISHED, + data: { id, errorMessage: getErrorMessage(error) }, + }, + EVENT_ACTION_DATASTORE_DELETE, + AMPLIFY_SYMBOL + ); + } +}; diff --git a/packages/react/src/hooks/actions/useDataStoreUpdateAction.ts b/packages/react/src/hooks/actions/useDataStoreUpdateAction.ts new file mode 100644 index 00000000000..7619281ef0e --- /dev/null +++ b/packages/react/src/hooks/actions/useDataStoreUpdateAction.ts @@ -0,0 +1,80 @@ +import { + ModelInit, + PersistentModel, + PersistentModelConstructor, +} from '@aws-amplify/datastore'; +import { DataStore, Hub } from 'aws-amplify'; + +import { + ACTION_DATASTORE_UPDATE_FINISHED, + ACTION_DATASTORE_UPDATE_STARTED, + DATASTORE_QUERY_BY_ID_ERROR, + EVENT_ACTION_DATASTORE_UPDATE, + UI_CHANNEL, +} from './constants'; +import { getErrorMessage } from '../../helpers/utils'; +import { AMPLIFY_SYMBOL } from '../../helpers/constants'; + +export interface UseDataStoreUpdateActionOptions< + Model extends PersistentModel +> { + model: PersistentModelConstructor; + id: string; + fields: ModelInit; +} + +/** + * Action to Update DataStore item + * @internal + */ +export const useDataStoreUpdateAction = ({ + model, + id, + fields, +}: UseDataStoreUpdateActionOptions) => async () => { + try { + Hub.dispatch( + UI_CHANNEL, + { + event: ACTION_DATASTORE_UPDATE_STARTED, + data: { fields, id }, + }, + EVENT_ACTION_DATASTORE_UPDATE, + AMPLIFY_SYMBOL + ); + + const original = await DataStore.query(model, id); + // If query by id doesn't return an item, + // original will be undefined + // so we'll log a helpful message. + if (!original) { + throw new Error(`${DATASTORE_QUERY_BY_ID_ERROR}: ${id}`); + } + + const item = await DataStore.save( + model.copyOf(original, (updated: any) => { + Object.assign(updated, fields); + }) + ); + + Hub.dispatch( + UI_CHANNEL, + { + event: ACTION_DATASTORE_UPDATE_FINISHED, + data: { fields, id, item }, + }, + EVENT_ACTION_DATASTORE_UPDATE, + AMPLIFY_SYMBOL + ); + } catch (error) { + Hub.dispatch( + UI_CHANNEL, + { + event: ACTION_DATASTORE_UPDATE_FINISHED, + data: { fields, id, errorMessage: getErrorMessage(error) }, + }, + EVENT_ACTION_DATASTORE_UPDATE, + AMPLIFY_SYMBOL + ); + } +}; diff --git a/packages/react/src/hooks/actions/useNavigateAction.ts b/packages/react/src/hooks/actions/useNavigateAction.ts new file mode 100644 index 00000000000..97900acf654 --- /dev/null +++ b/packages/react/src/hooks/actions/useNavigateAction.ts @@ -0,0 +1,81 @@ +import * as React from 'react'; +import { Hub } from 'aws-amplify'; + +import { + ACTION_NAVIGATE_FINISHED, + ACTION_NAVIGATE_STARTED, + EVENT_ACTION_CORE_NAVIGATE, + UI_CHANNEL, +} from './constants'; +import { AMPLIFY_SYMBOL } from '../../helpers/constants'; + +export type NavigateType = 'url' | 'anchor' | 'reload'; + +type NavigateRun = () => void; + +export interface UseNavigateActionOptions { + type: NavigateType; + + url?: string; + + anchor?: string; + + target?: React.HTMLAttributeAnchorTarget; +} + +export const windowFeatures = 'noopener noreferrer'; +export const defaultTarget = '_self'; + +/** + * Action to instruct user’s browser to change current location + * @internal + */ +export const useNavigateAction = (options: UseNavigateActionOptions) => { + const { type, url, anchor, target } = options; + const run: NavigateRun = React.useMemo(() => { + switch (type) { + case 'url': + return () => { + window.open(url, target ? target : defaultTarget, windowFeatures); + }; + case 'anchor': + return () => { + window.location.hash = anchor; + }; + case 'reload': + return () => { + window.location.reload(); + }; + default: + return () => { + console.warn( + 'Please provide a valid navigate type. Available types are "url", "anchor" and "reload".' + ); + }; + } + }, [anchor, target, type, url]); + + const navigateAction = () => { + Hub.dispatch( + UI_CHANNEL, + { + event: ACTION_NAVIGATE_STARTED, + data: options, + }, + EVENT_ACTION_CORE_NAVIGATE, + AMPLIFY_SYMBOL + ); + run(); + Hub.dispatch( + UI_CHANNEL, + { + event: ACTION_NAVIGATE_FINISHED, + data: options, + }, + EVENT_ACTION_CORE_NAVIGATE, + AMPLIFY_SYMBOL + ); + }; + + return navigateAction; +}; diff --git a/packages/react/src/hooks/actions/useStateMutationAction.ts b/packages/react/src/hooks/actions/useStateMutationAction.ts new file mode 100644 index 00000000000..34180aaa271 --- /dev/null +++ b/packages/react/src/hooks/actions/useStateMutationAction.ts @@ -0,0 +1,56 @@ +import * as React from 'react'; +import { Hub } from 'aws-amplify'; + +import { + ACTION_STATE_MUTATION_FINISHED, + ACTION_STATE_MUTATION_STARTED, + EVENT_ACTION_CORE_STATE_MUTATION, + UI_CHANNEL, +} from './constants'; +import { AMPLIFY_SYMBOL } from '../../helpers/constants'; + +type UseStateMutationAction = [ + StateType, + (newState: StateType) => void +]; + +/** + * Action to wrap React.useState with Hub events + * @internal + */ +export const useStateMutationAction = ( + initialState: StateType +): UseStateMutationAction => { + const [state, setState] = React.useState(initialState); + + const setNewState = React.useCallback( + (newState: StateType) => { + const prevState = state; + + Hub.dispatch( + UI_CHANNEL, + { + event: ACTION_STATE_MUTATION_STARTED, + data: { prevState, newState }, + }, + EVENT_ACTION_CORE_STATE_MUTATION, + AMPLIFY_SYMBOL + ); + + setState(newState); + + Hub.dispatch( + UI_CHANNEL, + { + event: ACTION_STATE_MUTATION_FINISHED, + data: { prevState, newState }, + }, + EVENT_ACTION_CORE_STATE_MUTATION, + AMPLIFY_SYMBOL + ); + }, + [state] + ); + + return [state, setNewState]; +}; diff --git a/packages/react/src/hooks/useNavigateAction.ts b/packages/react/src/hooks/useNavigateAction.ts deleted file mode 100644 index 27325a44a8c..00000000000 --- a/packages/react/src/hooks/useNavigateAction.ts +++ /dev/null @@ -1,3 +0,0 @@ -import * as React from 'react'; - -export const useNavigateAction = () => {}; diff --git a/packages/react/src/internal.tsx b/packages/react/src/internal.tsx index 6764a46a939..feacfe77136 100644 --- a/packages/react/src/internal.tsx +++ b/packages/react/src/internal.tsx @@ -1,6 +1,23 @@ export * from './hooks/useAuth'; export * from './hooks/useDataStore'; export * from './hooks/useStorageURL'; +export { + UseAuthSignOutAction, + useAuthSignOutAction, +} from './hooks/actions/useAuthSignOutAction'; +export { + useNavigateAction, + UseNavigateActionOptions, +} from './hooks/actions/useNavigateAction'; +export { useStateMutationAction } from './hooks/actions/useStateMutationAction'; + +export { useDataStoreCreateAction } from './hooks/actions/useDataStoreCreateAction'; +export type { UseDataStoreCreateActionOptions } from './hooks/actions/useDataStoreCreateAction'; +export { useDataStoreDeleteAction } from './hooks/actions/useDataStoreDeleteAction'; +export type { UseDataStoreDeleteActionOptions } from './hooks/actions/useDataStoreDeleteAction'; +export { useDataStoreUpdateAction } from './hooks/actions/useDataStoreUpdateAction'; +export type { UseDataStoreUpdateActionOptions } from './hooks/actions/useDataStoreUpdateAction'; + export * from './primitives/shared/datastore'; export { EscapeHatchProps,