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,