diff --git a/packages/aws-amplify-react-native/.eslintrc.js b/packages/aws-amplify-react-native/.eslintrc.js index ca002471774..fdd7b5b5b43 100644 --- a/packages/aws-amplify-react-native/.eslintrc.js +++ b/packages/aws-amplify-react-native/.eslintrc.js @@ -23,6 +23,15 @@ module.exports = { sourceType: 'module', }, plugins: ['react', '@typescript-eslint', 'react-hooks', 'jest', 'prettier'], + overrides: [{ + files: ['**/*.spec.*', '**/*.test.*'], + plugins: ['jest'], + rules: { + // turn the original rule off for test files + '@typescript-eslint/unbound-method': 'off', + 'jest/unbound-method': 'error', + }, + }], rules: { '@typescript-eslint/member-ordering': 'error', '@typescript-eslint/no-extra-semi': 'error', @@ -31,6 +40,7 @@ module.exports = { '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '_', varsIgnorePattern: '_' }], '@typescript-eslint/prefer-nullish-coalescing': 'error', '@typescript-eslint/restrict-template-expressions': ['off'], + '@typescript-eslint/unbound-method': 'error', 'comma-dangle': ['error', 'only-multiline'], 'function-paren-newline': 'off', 'generator-star-spacing': 'off', diff --git a/packages/aws-amplify-react-native/jest.config.js b/packages/aws-amplify-react-native/jest.config.js index d11af8c198a..c3985fb6073 100644 --- a/packages/aws-amplify-react-native/jest.config.js +++ b/packages/aws-amplify-react-native/jest.config.js @@ -1,4 +1,13 @@ module.exports = { preset: 'react-native', modulePathIgnorePatterns: ['/dist/'], + collectCoverageFrom: ['/src/**/*.{js,jsx,ts,tsx}', '!/src/**/*{c,C}onstants.ts'], + coverageThreshold: { + global: { + branches: 16, + functions: 9, + lines: 16, + statements: 16, + }, + }, }; diff --git a/packages/aws-amplify-react-native/package.json b/packages/aws-amplify-react-native/package.json index 627293922a5..671a6472bf3 100644 --- a/packages/aws-amplify-react-native/package.json +++ b/packages/aws-amplify-react-native/package.json @@ -4,7 +4,8 @@ "description": "AWS Amplify is a JavaScript library for Frontend and mobile developers building cloud-enabled applications.", "main": "dist/index.js", "scripts": { - "test": "npm run lint && jest -w 1 --passWithNoTests --coverage --maxWorkers 2 --config jest.config.js", + "test": "npm run lint && jest -w 1 --coverage --maxWorkers 2 --config jest.config.js", + "test:verbose": "npm run test -- --verbose", "build": "npm run lint && npm run clean && babel src --out-dir dist --extensions '.ts,.tsx,.js,.jsx' --copy-files", "watch": "npm run build -- --watch", "build:cjs:watch": "npm run watch", @@ -20,6 +21,7 @@ "@babel/core": "7.15.5", "@babel/preset-env": "7.15.6", "@react-native-picker/picker": "^1.2", + "@testing-library/react-hooks": "^7.0.2", "@types/react": "^16.13.1", "@types/react-native": "^0.63.45", "@types/react-test-renderer": "^17.0.1", diff --git a/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessage/__tests__/handleAction.spec.ts b/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessage/__tests__/handleAction.spec.ts new file mode 100644 index 00000000000..e30a115ca0f --- /dev/null +++ b/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessage/__tests__/handleAction.spec.ts @@ -0,0 +1,111 @@ +/* + * Copyright 2017-2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance with + * the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR + * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ + +import { Linking } from 'react-native'; +import { ConsoleLogger as Logger } from '@aws-amplify/core'; +import { InAppMessageAction } from '@aws-amplify/notifications'; + +import handleAction from '../handleAction'; + +jest.mock('react-native', () => ({ + Linking: { + canOpenURL: jest.fn(), + openURL: jest.fn(), + }, +})); + +const logger = new Logger('TEST_LOGGER'); + +const deepLink = 'DEEP_LINK'; +const link = 'LINK'; +const url = 'https://docs.amplify.aws/'; + +const error = 'ERROR'; + +describe('handleAction', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it.each([deepLink, link])('handles a %s action as expected in the happy path', async (action) => { + (Linking.canOpenURL as jest.Mock).mockResolvedValueOnce(true); + + await handleAction(action as InAppMessageAction, url); + + expect(logger.info).toHaveBeenCalledWith(`Handle action: ${action}`); + expect(Linking.canOpenURL).toHaveBeenCalledWith(url); + expect(logger.info).toHaveBeenCalledWith(`Opening url: ${url}`); + expect(Linking.openURL).toHaveBeenCalledWith(url); + expect(logger.info).toHaveBeenCalledTimes(2); + }); + + it.each([deepLink, link])( + 'logs a warning and early returns when a %s action is provided with a null url value', + async (action) => { + const invalidUrl = null; + + await handleAction(action as InAppMessageAction, invalidUrl); + + expect(logger.info).toHaveBeenCalledWith(`Handle action: ${action}`); + expect(logger.warn).toHaveBeenCalledWith(`url must be of type string: ${invalidUrl}`); + expect(logger.info).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(Linking.canOpenURL).not.toHaveBeenCalled(); + } + ); + + it.each([deepLink, link])( + 'logs a warning and early returns when a %s action is provided with an undefined url value', + async (action) => { + const invalidUrl = undefined; + + await handleAction(action as InAppMessageAction, invalidUrl); + + expect(logger.info).toHaveBeenCalledWith(`Handle action: ${action}`); + expect(logger.warn).toHaveBeenCalledWith(`url must be of type string: ${invalidUrl}`); + expect(logger.info).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(Linking.canOpenURL).not.toHaveBeenCalled(); + } + ); + + it('logs a warning when Linking.canOpenUrl returns false', async () => { + (Linking.canOpenURL as jest.Mock).mockResolvedValueOnce(false); + + await handleAction(link, url); + + expect(logger.info).toHaveBeenCalledWith(`Handle action: ${link}`); + expect(Linking.canOpenURL).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith(`Unsupported url provided: ${url}`); + expect(Linking.openURL).not.toHaveBeenCalled(); + }); + + it('logs an error when Linking.canOpenUrl fails', async () => { + (Linking.canOpenURL as jest.Mock).mockRejectedValueOnce(error); + + await handleAction(link, url); + + expect(logger.info).toHaveBeenCalledWith(`Handle action: ${link}`); + expect(logger.error).toHaveBeenCalledWith(`Call to Linking.canOpenURL failed: ${error}`); + }); + + it('logs an error when Linking.openUrl fails', async () => { + (Linking.canOpenURL as jest.Mock).mockResolvedValueOnce(true); + (Linking.openURL as jest.Mock).mockRejectedValue(error); + + await handleAction(link, url); + + expect(logger.info).toHaveBeenCalledWith(`Handle action: ${link}`); + expect(logger.error).toHaveBeenCalledWith(`Call to Linking.openURL failed: ${error}`); + }); +}); diff --git a/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessage/__tests__/useMessage.spec.ts b/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessage/__tests__/useMessage.spec.ts new file mode 100644 index 00000000000..04804dd6114 --- /dev/null +++ b/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessage/__tests__/useMessage.spec.ts @@ -0,0 +1,218 @@ +/* + * Copyright 2017-2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance with + * the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR + * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ + +import { InAppMessage, InAppMessageInteractionEvent, Notifications } from '@aws-amplify/notifications'; +import { ConsoleLogger as Logger } from '@aws-amplify/core'; + +import useInAppMessaging from '../../../../hooks/useInAppMessaging'; +import BannerMessage from '../../../BannerMessage'; +import CarouselMessage from '../../../CarouselMessage'; +import FullScreenMessage from '../../../FullScreenMessage'; +import ModalMessage from '../../../ModalMessage'; +import { InAppMessageComponentBaseProps } from '../../../types'; + +import useMessage from '../useMessage'; + +jest.mock('@aws-amplify/notifications', () => ({ + ...jest.requireActual('@aws-amplify/notifications'), + Notifications: { InAppMessaging: { notifyMessageInteraction: jest.fn() } }, +})); + +jest.mock('../../../../hooks/useInAppMessaging', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.useFakeTimers(); + +const logger = new Logger('TEST_LOGGER'); + +const mockUseInAppMessaging = useInAppMessaging as jest.Mock; +const mockClearInAppMessage = jest.fn(); + +const header = { content: 'header one' }; +const baseInAppMessage: Partial = { id: 'test', content: [{ header }] }; +const carouselInAppMessage: Partial = { + id: 'carousel', + content: [{ header }, { header: { content: 'header two' } }], + layout: 'CAROUSEL', +}; + +function CustomBannerMessage() { + return null; +} +function CustomCarouselMessage() { + return null; +} +function CustomFullScreenMessage() { + return null; +} +function CustomModalMessage() { + return null; +} + +describe('useMessage', () => { + beforeEach(() => { + (logger.info as jest.Mock).mockClear(); + }); + + // happy path test for banner and full screen layouts + it.each([ + ['BOTTOM_BANNER', BannerMessage, { position: 'bottom' }], + ['FULL_SCREEN', FullScreenMessage, null], + ['MIDDLE_BANNER', BannerMessage, { position: 'middle' }], + ['TOP_BANNER', BannerMessage, { position: 'top' }], + ['MODAL', ModalMessage, null], + ])('returns the expected values of Component and props for a %s layout', (layout, layoutComponent, layoutProps) => { + mockUseInAppMessaging.mockReturnValueOnce({ components: {}, inAppMessage: { ...baseInAppMessage, layout } }); + const { Component, props } = useMessage(); + + expect(Component).toBe(layoutComponent); + expect(props).toEqual( + expect.objectContaining({ + ...layoutProps, + header, + layout, + onClose: expect.any(Function) as InAppMessageComponentBaseProps['onClose'], + onDisplay: expect.any(Function) as InAppMessageComponentBaseProps['onDisplay'], + style: undefined, + }) + ); + }); + + it('returns the expected values of Component and props for a CAROUSEL layout', () => { + mockUseInAppMessaging.mockReturnValueOnce({ components: {}, inAppMessage: carouselInAppMessage }); + + const { Component, props } = useMessage(); + + expect(Component).toBe(CarouselMessage); + expect(props).toEqual( + expect.objectContaining({ + data: [{ header }, { header: { content: 'header two' } }], + layout: 'CAROUSEL', + onClose: expect.any(Function) as InAppMessageComponentBaseProps['onClose'], + onDisplay: expect.any(Function) as InAppMessageComponentBaseProps['onDisplay'], + style: undefined, + }) + ); + }); + + it.each([ + ['BannerMessage', 'BOTTOM_BANNER', CustomBannerMessage], + ['BannerMessage', 'MIDDLE_BANNER', CustomBannerMessage], + ['BannerMessage', 'TOP_BANNER', CustomBannerMessage], + ['CarouselMessage', 'CAROUSEL', CustomCarouselMessage], + ['FullScreenMessage', 'FULL_SCREEN', CustomFullScreenMessage], + ['ModalMessage', 'MODAL', CustomModalMessage], + ])( + 'returns a custom %s component for a %s layout in place of the default component when provided', + (componentKey, layout, CustomComponent) => { + mockUseInAppMessaging.mockReturnValueOnce({ + components: { [componentKey]: CustomComponent }, + inAppMessage: { layout }, + }); + + const { Component } = useMessage(); + + expect(Component).toBe(CustomComponent); + } + ); + + it('returns null values for Component and props when inAppMessage is null', () => { + mockUseInAppMessaging.mockReturnValueOnce({ components: {}, inAppMessage: null }); + + const { Component, props } = useMessage(); + + expect(Component).toBeNull(); + expect(props).toBeNull(); + }); + + it('returns null values for Component and props when inAppMessage.layout is not supported', () => { + const layout = 'NOT_A_SUPPORTED_LAYOUT'; + mockUseInAppMessaging.mockReturnValueOnce({ + components: {}, + inAppMessage: { layout }, + }); + + const { Component, props } = useMessage(); + + expect(logger.info).toHaveBeenCalledWith(`Received unknown InAppMessage layout: ${layout}`); + expect(logger.info).toHaveBeenCalledTimes(1); + expect(Component).toBeNull(); + expect(props).toBeNull(); + }); + + describe('event handling', () => { + const inAppMessage = { + content: [{ primaryButton: { action: 'CLOSE', title: 'primary' } }], + layout: 'TOP_BANNER', + }; + + beforeEach(() => { + mockUseInAppMessaging.mockReturnValueOnce({ + clearInAppMessage: mockClearInAppMessage, + components: {}, + inAppMessage, + }); + + mockClearInAppMessage.mockClear(); + (Notifications.InAppMessaging.notifyMessageInteraction as jest.Mock).mockClear(); + }); + + describe('onClose', () => { + it('calls the expected methods', () => { + const { props } = useMessage(); + + props.onClose(); + + expect(Notifications.InAppMessaging.notifyMessageInteraction).toHaveBeenCalledTimes(1); + expect(Notifications.InAppMessaging.notifyMessageInteraction).toHaveBeenCalledWith( + inAppMessage, + InAppMessageInteractionEvent.MESSAGE_DISMISSED + ); + expect(mockClearInAppMessage).toHaveBeenCalledTimes(1); + }); + }); + + describe('onDisplay', () => { + it('calls the expected methods', () => { + const { props } = useMessage(); + + props.onDisplay(); + + expect(Notifications.InAppMessaging.notifyMessageInteraction).toHaveBeenCalledTimes(1); + expect(Notifications.InAppMessaging.notifyMessageInteraction).toHaveBeenCalledWith( + inAppMessage, + InAppMessageInteractionEvent.MESSAGE_DISPLAYED + ); + }); + }); + + describe('onActionCallback', () => { + it('calls the expected methods via the onPress function of the primary button', () => { + const { props } = useMessage(); + + props.primaryButton.onPress(); + + jest.runAllTimers(); + + expect(Notifications.InAppMessaging.notifyMessageInteraction).toHaveBeenCalledTimes(1); + expect(Notifications.InAppMessaging.notifyMessageInteraction).toHaveBeenCalledWith( + inAppMessage, + InAppMessageInteractionEvent.MESSAGE_ACTION_TAKEN + ); + expect(mockClearInAppMessage).toHaveBeenCalledTimes(1); + }); + }); + }); +}); diff --git a/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessage/__tests__/utils.spec.ts b/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessage/__tests__/utils.spec.ts new file mode 100644 index 00000000000..db29c4de4bf --- /dev/null +++ b/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessage/__tests__/utils.spec.ts @@ -0,0 +1,124 @@ +/* + * Copyright 2017-2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance with + * the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR + * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ + +import { InAppMessageButton, InAppMessageContent, InAppMessageLayout } from '@aws-amplify/notifications'; +import { ConsoleLogger as Logger } from '@aws-amplify/core'; +import { InAppMessageComponentActionHandler } from '../types'; +import { getActionHandler, getContentProps, getPositionProp } from '../utils'; + +import handleAction from '../handleAction'; + +jest.mock('../handleAction', () => ({ + __esModule: true, + default: jest.fn(), +})); + +const logger = new Logger('TEST_LOGGER'); + +const baseContent: InAppMessageContent = { + container: { style: { backgroundColor: 'purple' } }, +}; + +const primaryButton: InAppMessageButton = { + action: 'LINK', + title: 'Go to docs', + url: 'https://docs.amplify.aws/', +}; + +const secondaryButton: InAppMessageButton = { + action: 'CLOSE', + title: 'close', +}; + +const onActionCallback = jest.fn(); + +describe('getPositionProp', () => { + it.each([ + ['TOP_BANNER', 'top'], + ['MIDDLE_BANNER', 'middle'], + ['BOTTOM_BANNER', 'bottom'], + ])('returns the expected position when provided a %s argument', (layout, expected) => { + const output = getPositionProp(layout as InAppMessageLayout); + expect(output).toBe(expected); + }); + + it('returns null when provided an unhandled layout argument', () => { + const output = getPositionProp('LEFT_BANNER' as InAppMessageLayout); + expect(output).toBeNull(); + }); +}); + +describe('getContentProps', () => { + it('returns the expected output in the happy path', () => { + const output = getContentProps(baseContent, onActionCallback); + expect(output).toStrictEqual(baseContent); + }); + + it('returns the expected output when a primary button is provided', () => { + const output = getContentProps({ ...baseContent, primaryButton }, onActionCallback); + expect(output).toStrictEqual({ + ...baseContent, + primaryButton: { + title: primaryButton.title, + onPress: expect.any(Function) as InAppMessageComponentActionHandler, + }, + }); + }); + + it('returns the expected output when a secondary button is provided', () => { + const output = getContentProps({ ...baseContent, secondaryButton }, onActionCallback); + expect(output).toStrictEqual({ + ...baseContent, + secondaryButton: { + title: secondaryButton.title, + onPress: expect.any(Function) as InAppMessageComponentActionHandler, + }, + }); + }); + + it('returns an empty props object when content is null', () => { + const output = getContentProps(null, onActionCallback); + expect(output).toStrictEqual({}); + }); +}); + +describe('getActionHandler', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('behaves as expected in the happy path', async () => { + const actionHandler = getActionHandler({ ...secondaryButton }, onActionCallback); + + await actionHandler.onPress(); + + expect(handleAction).toHaveBeenCalledTimes(1); + expect(onActionCallback).toHaveBeenCalledTimes(1); + }); + + it('behaves as expected when handleAction results in an error', async () => { + const error = 'ERROR'; + (handleAction as jest.Mock).mockImplementationOnce(() => { + throw new Error(error); + }); + + const actionHandler = getActionHandler({ ...secondaryButton }, onActionCallback); + + await actionHandler.onPress(); + + expect(handleAction).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalledWith(`handleAction failure: Error: ${error}`); + expect(onActionCallback).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessage/handleAction.ts b/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessage/handleAction.ts index 7de05ef7db4..564baf738bc 100644 --- a/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessage/handleAction.ts +++ b/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessage/handleAction.ts @@ -13,6 +13,7 @@ import { Linking } from 'react-native'; import { ConsoleLogger as Logger } from '@aws-amplify/core'; +import isString from 'lodash/isString'; import { InAppMessageComponentActionHandler } from './types'; @@ -21,24 +22,29 @@ const logger = new Logger('Notifications.InAppMessaging'); const handleAction: InAppMessageComponentActionHandler = async (action, url) => { logger.info(`Handle action: ${action}`); - if ((action === 'LINK' || action === 'DEEP_LINK') && url) { - let supported; + if (action === 'LINK' || action === 'DEEP_LINK') { + if (!isString(url)) { + logger.warn(`url must be of type string: ${url}`); + return; + } + + let supported: boolean; try { supported = await Linking.canOpenURL(url); } catch (e) { logger.error(`Call to Linking.canOpenURL failed: ${e}`); } - if (supported) { - try { - logger.info(`Opening url: ${url}`); - await Linking.openURL(url); - } catch (e) { - logger.error(`Call to Linking.openURL failed: ${e}`); - } - } else { - // TODO: determine how to allow for custom reporting of this scenario - logger.warn(`Unsupported url given: ${url}`); + if (!supported) { + logger.warn(`Unsupported url provided: ${url}`); + return; + } + + try { + logger.info(`Opening url: ${url}`); + await Linking.openURL(url); + } catch (e) { + logger.error(`Call to Linking.openURL failed: ${e}`); } } }; diff --git a/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessage/useMessage.tsx b/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessage/useMessage.ts similarity index 100% rename from packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessage/useMessage.tsx rename to packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessage/useMessage.ts diff --git a/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessage/utils.ts b/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessage/utils.ts index 8ecd9e25678..12d7b8b78f2 100644 --- a/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessage/utils.ts +++ b/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessage/utils.ts @@ -46,7 +46,7 @@ export const getPositionProp = (layout: InAppMessageLayout): InAppMessageCompone } }; -const getActionHandler = ( +export const getActionHandler = ( { action, url }: { action: InAppMessageAction; url?: string }, onActionCallback: () => void ) => ({ diff --git a/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessageImage/__tests__/useMessageImage.spec.ts b/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessageImage/__tests__/useMessageImage.spec.ts new file mode 100644 index 00000000000..59af0ce819a --- /dev/null +++ b/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessageImage/__tests__/useMessageImage.spec.ts @@ -0,0 +1,137 @@ +/* + * Copyright 2017-2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance with + * the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR + * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ + +import { Image } from 'react-native'; +import { renderHook } from '@testing-library/react-hooks'; +import { ConsoleLogger as Logger } from '@aws-amplify/core'; +import { InAppMessageImage } from '@aws-amplify/notifications'; + +import { INITIAL_IMAGE_DIMENSIONS } from '../constants'; +import { getLayoutImageDimensions, prefetchNetworkImage } from '../utils'; +import useMessageImage from '../useMessageImage'; + +jest.mock('react-native', () => ({ + Dimensions: { get: jest.fn(() => ({ height: 844, width: 400 })) }, + Image: { getSize: jest.fn() }, +})); + +jest.mock('../utils'); + +const logger = new Logger('TEST_LOGGER'); + +const src = 'https://test.jpeg'; +const image = { src }; + +describe('useMessageImage', () => { + beforeEach(() => { + (logger.error as jest.Mock).mockClear(); + }); + + it('behaves as expected in the happy path', async () => { + const imageDimensions = { height: 100, width: 100 }; + (prefetchNetworkImage as jest.Mock).mockResolvedValue('loaded'); + (getLayoutImageDimensions as jest.Mock).mockReturnValueOnce(imageDimensions); + (Image.getSize as jest.Mock).mockImplementationOnce((_, onSuccess: () => void) => { + onSuccess(); + }); + + const { result, waitForNextUpdate } = renderHook(() => useMessageImage(image, 'TOP_BANNER')); + + // first render + expect(result.current).toStrictEqual({ + hasRenderableImage: false, + imageDimensions: INITIAL_IMAGE_DIMENSIONS, + isImageFetching: true, + }); + + await waitForNextUpdate(); + + expect(result.current).toStrictEqual({ + hasRenderableImage: true, + imageDimensions, + isImageFetching: false, + }); + }); + + it('handles size retrieval errors as expected', async () => { + const error = 'ERROR'; + + (prefetchNetworkImage as jest.Mock).mockResolvedValue('loaded'); + (Image.getSize as jest.Mock).mockImplementationOnce((_, __, onError: (error) => void) => { + onError(error); + }); + + const { result, waitForNextUpdate } = renderHook(() => useMessageImage(image, 'TOP_BANNER')); + + // first render + expect(result.current).toStrictEqual({ + hasRenderableImage: false, + imageDimensions: INITIAL_IMAGE_DIMENSIONS, + isImageFetching: true, + }); + + await waitForNextUpdate(); + + expect(logger.error).toHaveBeenCalledWith(`Unable to retrieve size for image: ${error}`); + expect(logger.error).toHaveBeenCalledTimes(1); + + expect(result.current).toStrictEqual({ + hasRenderableImage: false, + imageDimensions: INITIAL_IMAGE_DIMENSIONS, + isImageFetching: false, + }); + }); + + it('handles prefetching errors as expected', async () => { + (prefetchNetworkImage as jest.Mock).mockResolvedValue('failed'); + + const { result, waitForNextUpdate } = renderHook(() => useMessageImage(image, 'TOP_BANNER')); + + // first render + expect(result.current).toStrictEqual({ + hasRenderableImage: false, + imageDimensions: INITIAL_IMAGE_DIMENSIONS, + isImageFetching: true, + }); + + await waitForNextUpdate(); + + expect(logger.error).not.toHaveBeenCalled(); + + expect(result.current).toStrictEqual({ + hasRenderableImage: false, + imageDimensions: INITIAL_IMAGE_DIMENSIONS, + isImageFetching: false, + }); + }); + + it('returns the expected values when the image argument is an empty object', () => { + const { result } = renderHook(() => useMessageImage({} as InAppMessageImage, 'TOP_BANNER')); + + expect(result.current).toStrictEqual({ + hasRenderableImage: false, + imageDimensions: INITIAL_IMAGE_DIMENSIONS, + isImageFetching: false, + }); + }); + + it('returns the expected values when the image argument is null', () => { + const { result } = renderHook(() => useMessageImage(null, 'TOP_BANNER')); + + expect(result.current).toStrictEqual({ + hasRenderableImage: false, + imageDimensions: INITIAL_IMAGE_DIMENSIONS, + isImageFetching: false, + }); + }); +}); diff --git a/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessageImage/__tests__/utils.spec.ts b/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessageImage/__tests__/utils.spec.ts new file mode 100644 index 00000000000..8c5473f5d96 --- /dev/null +++ b/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessageImage/__tests__/utils.spec.ts @@ -0,0 +1,102 @@ +/* + * Copyright 2017-2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance with + * the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR + * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ + +import { Image } from 'react-native'; +import { ConsoleLogger as Logger } from '@aws-amplify/core'; + +import { BANNER_IMAGE_SCREEN_MULTIPLIER, BANNER_IMAGE_SCREEN_SIZE } from '../constants'; +import { getLayoutImageDimensions, prefetchNetworkImage } from '../utils'; + +jest.mock('react-native', () => ({ + Dimensions: { get: jest.fn(() => ({ height: 844, width: 400 })) }, + Image: { prefetch: jest.fn() }, +})); + +const logger = new Logger('TEST_LOGGER'); + +const url = 'https://test.jpeg'; + +describe('prefetchNetworkImage', () => { + beforeEach(() => { + (logger.error as jest.Mock).mockClear(); + }); + + it('behaves as expected in the happy path', async () => { + (Image.prefetch as jest.Mock).mockResolvedValueOnce(true); + + const output = await prefetchNetworkImage(url); + + expect(output).toBe('loaded'); + }); + + it('handles a false response from Imaage.prefetch as expected', async () => { + (Image.prefetch as jest.Mock).mockResolvedValueOnce(false); + + const output = await prefetchNetworkImage(url); + + expect(logger.error).toHaveBeenLastCalledWith(`Image failed to load: ${url}`); + expect(logger.error).toHaveBeenCalledTimes(1); + + expect(output).toBe('failed'); + }); + + it('handles an error from Imaage.prefetch as expected', async () => { + const error = 'ERROR'; + (Image.prefetch as jest.Mock).mockRejectedValueOnce(new Error(error)); + + const output = await prefetchNetworkImage(url); + + expect(logger.error).toHaveBeenLastCalledWith(`Image.prefetch failed: Error: ${error}`); + expect(logger.error).toHaveBeenCalledTimes(1); + + expect(output).toBe('failed'); + }); +}); + +describe('getLayoutImageDimensions', () => { + it('returns the expected values for a square image', () => { + const imageHeight = 100; + const imageWidth = 100; + + const output = getLayoutImageDimensions(imageHeight, imageWidth, 'TOP_BANNER'); + + expect(output).toStrictEqual({ + height: BANNER_IMAGE_SCREEN_SIZE, + width: BANNER_IMAGE_SCREEN_SIZE, + }); + }); + + it('returns the expected values for a portrait image', () => { + const imageHeight = 200; + const imageWidth = 100; + + const output = getLayoutImageDimensions(imageHeight, imageWidth, 'TOP_BANNER'); + + expect(output).toStrictEqual({ + height: BANNER_IMAGE_SCREEN_SIZE, + width: imageHeight * BANNER_IMAGE_SCREEN_MULTIPLIER, + }); + }); + + it('returns the expected values for a landscape image', () => { + const imageHeight = 100; + const imageWidth = 200; + + const output = getLayoutImageDimensions(imageHeight, imageWidth, 'TOP_BANNER'); + + expect(output).toStrictEqual({ + height: imageWidth * BANNER_IMAGE_SCREEN_MULTIPLIER, + width: BANNER_IMAGE_SCREEN_SIZE, + }); + }); +}); diff --git a/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessageImage/constants.ts b/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessageImage/constants.ts index 19a47075e54..5e1a86daf9e 100644 --- a/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessageImage/constants.ts +++ b/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessageImage/constants.ts @@ -12,6 +12,7 @@ */ import { Dimensions } from 'react-native'; +import { ImageDimensions } from './types'; // as images are not expected to be responsive to orientation changes get screen dimensions at app start const SCREEN_DIMENSIONS = Dimensions.get('screen'); @@ -23,7 +24,14 @@ const BASE_SCREEN_DIMENSION = // base size that message images should fill // - all banner message images should fill 20 percent of the base screen dimension // - all other components should fill 60 percent of the base screen dimension -export const BANNER_IMAGE_SCREEN_SIZE = 0.2 * BASE_SCREEN_DIMENSION; -export const CAROUSEL_IMAGE_SCREEN_SIZE = 0.6 * BASE_SCREEN_DIMENSION; -export const FULL_SCREEN_IMAGE_SCREEN_SIZE = 0.6 * BASE_SCREEN_DIMENSION; -export const MODAL_IMAGE_SCREEN_SIZE = 0.6 * BASE_SCREEN_DIMENSION; +export const BANNER_IMAGE_SCREEN_MULTIPLIER = 0.2; +export const CAROUSEL_IMAGE_SCREEN_MULTIPLIER = 0.6; +export const FULL_SCREEN_IMAGE_SCREEN_MULTIPLIER = 0.6; +export const MODAL_IMAGE_SCREEN_MULTIPLIER = 0.6; + +export const BANNER_IMAGE_SCREEN_SIZE = BANNER_IMAGE_SCREEN_MULTIPLIER * BASE_SCREEN_DIMENSION; +export const CAROUSEL_IMAGE_SCREEN_SIZE = CAROUSEL_IMAGE_SCREEN_MULTIPLIER * BASE_SCREEN_DIMENSION; +export const FULL_SCREEN_IMAGE_SCREEN_SIZE = FULL_SCREEN_IMAGE_SCREEN_MULTIPLIER * BASE_SCREEN_DIMENSION; +export const MODAL_IMAGE_SCREEN_SIZE = MODAL_IMAGE_SCREEN_MULTIPLIER * BASE_SCREEN_DIMENSION; + +export const INITIAL_IMAGE_DIMENSIONS: ImageDimensions = { height: null, width: null }; diff --git a/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessageImage/types.ts b/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessageImage/types.ts index 685c9731512..fdc38faec1d 100644 --- a/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessageImage/types.ts +++ b/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessageImage/types.ts @@ -16,7 +16,14 @@ export type ImageDimensions = { width: number; }; -export type ImageLoadingState = 'loading' | 'loaded' | 'failed'; +export type ImageLoadingState = 'loaded' | 'failed'; + +export enum ImagePrefetchStatus { + INITIAL = 'INITIAL', + FETCHING = 'FETCHING', + SUCCESS = 'SUCCESS', + FAILURE = 'FAILURE', +} export type UseMessageImage = { hasRenderableImage: boolean; diff --git a/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessageImage/useMessageImage.ts b/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessageImage/useMessageImage.ts index 212f0413596..a5e837c8925 100644 --- a/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessageImage/useMessageImage.ts +++ b/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessageImage/useMessageImage.ts @@ -11,18 +11,16 @@ * and limitations under the License. */ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Image } from 'react-native'; -import isNull from 'lodash/isNull'; import { ConsoleLogger as Logger } from '@aws-amplify/core'; import { InAppMessageImage, InAppMessageLayout } from '@aws-amplify/notifications'; -import { ImageDimensions, UseMessageImage } from './types'; +import { INITIAL_IMAGE_DIMENSIONS } from './constants'; +import { ImageDimensions, ImagePrefetchStatus, UseMessageImage } from './types'; import { getLayoutImageDimensions, prefetchNetworkImage } from './utils'; -const FAILURE_IMAGE_DIMENSIONS: ImageDimensions = { height: 0, width: 0 }; - const logger = new Logger('Notifications.InAppMessaging'); /** @@ -34,39 +32,47 @@ const logger = new Logger('Notifications.InAppMessaging'); */ export default function useMessageImage(image: InAppMessageImage, layout: InAppMessageLayout): UseMessageImage { - const [imageDimensions, setImageDimensions] = useState(null); const { src } = image ?? {}; + const shouldPrefetch = !!src; - const hasSetDimensions = !isNull(imageDimensions); - const hasImage = !!src; + // set initial status to fetching if prefetch is required + const [prefetchStatus, setPrefetchStatus] = useState( + shouldPrefetch ? ImagePrefetchStatus.FETCHING : null + ); + const imageDimensions = useRef(INITIAL_IMAGE_DIMENSIONS).current; - const hasRenderableImage = hasImage && hasSetDimensions; - const isImageFetching = hasImage && !hasSetDimensions; + const isImageFetching = prefetchStatus === ImagePrefetchStatus.FETCHING; + const hasRenderableImage = prefetchStatus === ImagePrefetchStatus.SUCCESS; useEffect(() => { - if (hasImage) { - prefetchNetworkImage(src).then((loadingState) => { - if (loadingState === 'loaded') { - // get image size once loaded - Image.getSize( - src, - (width, height) => { - setImageDimensions(getLayoutImageDimensions(height, width, layout)); - }, - (error) => { - logger.error(`Unable to retrieve size for image: ${error}`); - - // set failure dimension values on size retrieval failure - setImageDimensions(FAILURE_IMAGE_DIMENSIONS); - } - ); - } else { - // set failure dimension values on prefetch failure - setImageDimensions(FAILURE_IMAGE_DIMENSIONS); - } - }); + if (!shouldPrefetch) { + return; } - }, [hasImage, layout, src]); + + prefetchNetworkImage(src).then((prefetchResult) => { + if (prefetchResult === 'loaded') { + // get image size once loaded + Image.getSize( + src, + (imageWidth, imageHeight) => { + const { height, width } = getLayoutImageDimensions(imageHeight, imageWidth, layout); + imageDimensions.height = height; + imageDimensions.width = width; + + setPrefetchStatus(ImagePrefetchStatus.SUCCESS); + }, + (error) => { + // handle size retrieval error + logger.error(`Unable to retrieve size for image: ${error}`); + setPrefetchStatus(ImagePrefetchStatus.FAILURE); + } + ); + } else { + // handle prefetch failure + setPrefetchStatus(ImagePrefetchStatus.FAILURE); + } + }); + }, [imageDimensions, layout, shouldPrefetch, src]); return { hasRenderableImage, imageDimensions, isImageFetching }; } diff --git a/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessageImage/utils.ts b/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessageImage/utils.ts index a2d8015ba21..c44fa4ed562 100644 --- a/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessageImage/utils.ts +++ b/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessageImage/utils.ts @@ -44,7 +44,7 @@ export const prefetchNetworkImage = async (url: string): Promise ({ + __esModule: true, + default: jest.fn(), +})); + +const mockUseMessageImage = useMessageImage as jest.Mock; + +const onDisplay = jest.fn(); +const getDefaultStyle = jest.fn(); + +describe('useMessageProps', () => { + beforeEach(() => { + mockUseMessageImage.mockReturnValue({ hasRenderableImage: false, imageDimensions: null, isImageFetching: false }); + onDisplay.mockClear(); + }); + + it('behaves as expected in the happy path', () => { + const props: InAppMessageComponentBaseProps = { layout: 'MIDDLE_BANNER', onDisplay }; + + const { result } = renderHook(() => useMessageProps(props, getDefaultStyle)); + + expect(onDisplay).toHaveBeenCalledTimes(1); + expect(result.current).toEqual({ + hasButtons: false, + hasPrimaryButton: false, + hasRenderableImage: false, + hasSecondaryButton: false, + shouldRenderMessage: true, + styles: expect.any(Object) as InAppMessageComponentStyle, + }); + }); + + it('behaves as expected when props includes an image', () => { + mockUseMessageImage.mockReturnValue({ hasRenderableImage: false, imageDimensions: null, isImageFetching: true }); + + const props: InAppMessageComponentBaseProps = { + image: { src: 'https://test.png' }, + layout: 'MIDDLE_BANNER', + onDisplay, + }; + + const { result, rerender } = renderHook(() => useMessageProps(props, getDefaultStyle)); + + // first render + expect(onDisplay).not.toHaveBeenCalled(); + expect(result.current).toEqual({ + hasButtons: false, + hasPrimaryButton: false, + hasRenderableImage: false, + hasSecondaryButton: false, + shouldRenderMessage: false, + styles: null, + }); + + mockUseMessageImage.mockReturnValue({ + hasRenderableImage: true, + imageDimensions: { height: 12, width: 12 }, + isImageFetching: false, + }); + + rerender(); + + expect(onDisplay).toHaveBeenCalledTimes(1); + expect(result.current).toEqual({ + hasButtons: false, + hasPrimaryButton: false, + hasRenderableImage: true, + hasSecondaryButton: false, + shouldRenderMessage: true, + styles: expect.any(Object) as InAppMessageComponentStyle, + }); + }); + + it('returns the expected values when props includes buttons', () => { + const props: InAppMessageComponentBaseProps = { + layout: 'MIDDLE_BANNER', + + primaryButton: { title: 'primary', onPress: jest.fn() }, + secondaryButton: { title: 'secondary', onPress: jest.fn() }, + }; + + const { result } = renderHook(() => useMessageProps(props, getDefaultStyle)); + + expect(result.current.hasButtons).toBe(true); + expect(result.current.hasPrimaryButton).toBe(true); + expect(result.current.hasSecondaryButton).toBe(true); + }); + + it('returns the expected values when props includes empty buttons', () => { + const props: InAppMessageComponentBaseProps = { + layout: 'MIDDLE_BANNER', + + primaryButton: {} as InAppMessageComponentButtonProps, + secondaryButton: {} as InAppMessageComponentButtonProps, + }; + + const { result } = renderHook(() => useMessageProps(props, getDefaultStyle)); + + expect(result.current.hasButtons).toBe(false); + expect(result.current.hasPrimaryButton).toBe(false); + expect(result.current.hasSecondaryButton).toBe(false); + }); + + it('returns the expected values when props includes only a primary button', () => { + const props: InAppMessageComponentBaseProps = { + layout: 'MIDDLE_BANNER', + primaryButton: { title: 'primary', onPress: jest.fn() }, + }; + + const { result } = renderHook(() => useMessageProps(props, getDefaultStyle)); + + expect(result.current.hasButtons).toBe(true); + expect(result.current.hasPrimaryButton).toBe(true); + expect(result.current.hasSecondaryButton).toBe(false); + }); + + it('returns the expected values when props includes only a secondary button', () => { + const props: InAppMessageComponentBaseProps = { + layout: 'MIDDLE_BANNER', + secondaryButton: { title: 'primary', onPress: jest.fn() }, + }; + + const { result } = renderHook(() => useMessageProps(props, getDefaultStyle)); + + expect(result.current.hasButtons).toBe(true); + expect(result.current.hasPrimaryButton).toBe(false); + expect(result.current.hasSecondaryButton).toBe(true); + }); +}); diff --git a/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessageProps/__tests__/utils.spec.ts b/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessageProps/__tests__/utils.spec.ts new file mode 100644 index 00000000000..1c8dfe0c1d6 --- /dev/null +++ b/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessageProps/__tests__/utils.spec.ts @@ -0,0 +1,324 @@ +/* + * Copyright 2017-2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance with + * the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR + * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ + +import { PressableStateCallbackType, StyleProp, ViewStyle } from 'react-native'; +import { InAppMessageTextAlign } from '@aws-amplify/notifications'; + +import { BUTTON_PRESSED_OPACITY } from '../../../constants'; +import { InAppMessageComponentBaseProps, InAppMessageComponentBaseStyle } from '../../../types'; +import { StyleParams } from '../types'; + +import { getComponentButtonStyle, getContainerAndWrapperStyle, getMessageStyle, getMessageStyleProps } from '../utils'; + +type ResolveContainerStyle = { container: (state?: PressableStateCallbackType) => StyleProp }; + +const EMPTY_STYLE = Object.freeze({}); + +describe('getComponentButtonStyle', () => { + const pressedOpacity = { opacity: BUTTON_PRESSED_OPACITY }; + + it.each(['primaryButton' as const, 'secondaryButton' as const])( + 'returns the expected output in the happy path for a %s', + (buttonType) => { + const defaultStyle = { + buttonContainer: { backgroundColor: 'white' }, + buttonText: { color: 'red' }, + } as InAppMessageComponentBaseStyle; + const messageStyle = { [buttonType]: { backgroundColor: 'maroon', borderRadius: 4, color: 'teal' } }; + const overrideStyle = { [buttonType]: { container: { backgroundColor: 'pink' }, text: { color: 'black' } } }; + const styleParams = { defaultStyle, messageStyle, overrideStyle }; + + const expectedContainerPressedStyle = [ + pressedOpacity, + { backgroundColor: 'white' }, + { backgroundColor: 'maroon', borderRadius: 4 }, + { backgroundColor: 'pink' }, + ]; + const expectedContainerUnpressedStyle = [ + EMPTY_STYLE, + { backgroundColor: 'white' }, + { backgroundColor: 'maroon', borderRadius: 4 }, + { backgroundColor: 'pink' }, + ]; + + const expectedTextStyle = [{ color: 'red' }, { color: 'teal' }, { color: 'black' }]; + + const output = getComponentButtonStyle({ styleParams, buttonType }); + + const containerPressedStyle = (output as ResolveContainerStyle).container({ pressed: true }); + const containerUnpressedStyle = (output as ResolveContainerStyle).container({ pressed: false }); + + expect(containerPressedStyle).toStrictEqual(expectedContainerPressedStyle); + expect(containerUnpressedStyle).toStrictEqual(expectedContainerUnpressedStyle); + + expect(output.text).toStrictEqual(expectedTextStyle); + } + ); + + it.each([{}, null])('correctly handles a value of %s passed as messageStyle.primaryButton', (messageStyle) => { + const defaultStyle = { + buttonContainer: { backgroundColor: 'white' }, + buttonText: { color: 'red' }, + } as InAppMessageComponentBaseStyle; + const styleParams = { defaultStyle, messageStyle, overrideStyle: null }; + + const output = getComponentButtonStyle({ styleParams, buttonType: 'primaryButton' }); + + const buttonContainerStyle = (output as ResolveContainerStyle).container({ pressed: true }); + + expect(buttonContainerStyle).toStrictEqual([ + pressedOpacity, + { backgroundColor: 'white' }, + EMPTY_STYLE, + EMPTY_STYLE, + ]); + expect(output.text).toStrictEqual([{ color: 'red' }, EMPTY_STYLE, EMPTY_STYLE]); + }); + + describe('button container style', () => { + it('returns unpressed button container style when press event is not provided', () => { + const defaultStyle = { + buttonContainer: { backgroundColor: 'white' }, + buttonText: {}, + } as InAppMessageComponentBaseStyle; + const styleParams = { defaultStyle, messageStyle: null, overrideStyle: null }; + + const output = getComponentButtonStyle({ styleParams, buttonType: 'primaryButton' }); + + const buttonContainerStyle = (output as ResolveContainerStyle).container(); + + const expectedButtonContainerStyle = [EMPTY_STYLE, { backgroundColor: 'white' }, EMPTY_STYLE, EMPTY_STYLE]; + + expect(buttonContainerStyle).toStrictEqual(expectedButtonContainerStyle); + }); + + it('correctly evaluates button container override style when it is a function', () => { + const pressedStyle = { backgroundColor: 'seafoam' }; + const unpressedStyle = { backgroundColor: 'fuschia' }; + const overrideStyle = { + primaryButton: { container: ({ pressed }) => (pressed ? pressedStyle : unpressedStyle) }, + }; + const styleParams = { defaultStyle: null, messageStyle: null, overrideStyle }; + + const output = getComponentButtonStyle({ styleParams, buttonType: 'primaryButton' }); + + const containerPressedStyle = (output as ResolveContainerStyle).container({ pressed: true }); + const containerUnpressedStyle = (output as ResolveContainerStyle).container({ pressed: false }); + + const expectedContainerPressedStyle = [pressedOpacity, EMPTY_STYLE, EMPTY_STYLE, pressedStyle]; + const expectedContainerUnressedStyle = [EMPTY_STYLE, EMPTY_STYLE, EMPTY_STYLE, unpressedStyle]; + + expect(containerPressedStyle).toStrictEqual(expectedContainerPressedStyle); + expect(containerUnpressedStyle).toStrictEqual(expectedContainerUnressedStyle); + }); + }); +}); + +describe('getContainerAndWrapperStyle', () => { + it('returns the expected output for a banner component in the happy path', () => { + const defaultStyle = { + container: { backgroundColor: 'red' }, + componentWrapper: { opacity: 0.4 }, + } as InAppMessageComponentBaseStyle; + const messageStyle = { container: { backgroundColor: 'teal' } }; + const overrideStyle = { container: { backgroundColor: 'pink' } }; + + const output = getContainerAndWrapperStyle({ + layout: 'TOP_BANNER', + styleParams: { defaultStyle, messageStyle, overrideStyle }, + }); + + const expectedContainerStyle = [ + { backgroundColor: 'red' }, + { backgroundColor: 'teal' }, + { backgroundColor: 'pink' }, + ]; + + const expectedWrapperStyle = { opacity: 0.4 }; + + expect(output.container).toStrictEqual(expectedContainerStyle); + expect(output.componentWrapper).toStrictEqual(expectedWrapperStyle); + }); + + it('returns the expected output for a non-banner component in the happy path', () => { + const defaultStyle = { + container: { backgroundColor: 'red' }, + componentWrapper: { opacity: 0.4 }, + } as InAppMessageComponentBaseStyle; + const messageStyle = { container: { backgroundColor: 'teal' } }; + const overrideStyle = { container: { backgroundColor: 'pink' } }; + + const output = getContainerAndWrapperStyle({ + layout: 'CAROUSEL', + styleParams: { defaultStyle, messageStyle, overrideStyle }, + }); + + const expectedContainerStyle = [{}, {}, {}]; + + const expectedWrapperStyle = [ + { opacity: 0.4 }, + { backgroundColor: 'red' }, + { backgroundColor: 'teal' }, + { backgroundColor: 'pink' }, + ]; + + expect(output.container).toStrictEqual(expectedContainerStyle); + expect(output.componentWrapper).toStrictEqual(expectedWrapperStyle); + }); + + it('correctly handles a style array passed as the argument of overrideStyle.container', () => { + const defaultStyle = { + container: { backgroundColor: 'red' }, + componentWrapper: { opacity: 0.4 }, + } as InAppMessageComponentBaseStyle; + const messageStyle = { container: { backgroundColor: 'teal' } }; + const overrideStyle = { container: [{ backgroundColor: 'pink' }, { flex: 5 }] }; + + const output = getContainerAndWrapperStyle({ + layout: 'CAROUSEL', + styleParams: { defaultStyle, messageStyle, overrideStyle }, + }); + + const expectedContainerStyle = [EMPTY_STYLE, EMPTY_STYLE, { flex: 5 }]; + + const expectedWrapperStyle = [ + { opacity: 0.4 }, + { backgroundColor: 'red' }, + { backgroundColor: 'teal' }, + { backgroundColor: 'pink' }, + ]; + + expect(output.container).toStrictEqual(expectedContainerStyle); + expect(output.componentWrapper).toStrictEqual(expectedWrapperStyle); + }); + + it('returns the expected output for a banner component with null style arguments', () => { + const defaultStyle: StyleParams['defaultStyle'] = null; + const messageStyle: StyleParams['messageStyle'] = null; + const overrideStyle: StyleParams['overrideStyle'] = null; + + const output = getContainerAndWrapperStyle({ + layout: 'BOTTOM_BANNER', + styleParams: { defaultStyle, messageStyle, overrideStyle }, + }); + + const expectedContainerStyle = [EMPTY_STYLE, EMPTY_STYLE, EMPTY_STYLE]; + + const expectedWrapperStyle = EMPTY_STYLE; + + expect(output.container).toStrictEqual(expectedContainerStyle); + expect(output.componentWrapper).toStrictEqual(expectedWrapperStyle); + }); + + it('returns the expected output for a non-banner component with empty style arguments', () => { + const defaultStyle: StyleParams['defaultStyle'] = null; + const messageStyle: StyleParams['messageStyle'] = null; + const overrideStyle: StyleParams['overrideStyle'] = null; + + const output = getContainerAndWrapperStyle({ + layout: 'MODAL', + styleParams: { defaultStyle, messageStyle, overrideStyle }, + }); + + const expectedContainerStyle = [EMPTY_STYLE, EMPTY_STYLE, EMPTY_STYLE]; + + const expectedWrapperStyle = [EMPTY_STYLE, EMPTY_STYLE, EMPTY_STYLE, EMPTY_STYLE]; + + expect(output.container).toStrictEqual(expectedContainerStyle); + expect(output.componentWrapper).toStrictEqual(expectedWrapperStyle); + }); +}); + +describe('getMessageStyle', () => { + it('returns the expected output in the happy path', () => { + const output = getMessageStyle({ + body: { style: { textAlign: 'left' as InAppMessageTextAlign } }, + container: { style: { backgroundColor: 'lightgray', borderRadius: 2 } }, + header: { style: { textAlign: 'center' as InAppMessageTextAlign } }, + primaryButton: { style: { backgroundColor: 'salmon', color: 'olive' } }, + secondaryButton: { style: { backgroundColor: 'sand', color: 'peru' } }, + } as InAppMessageComponentBaseProps); + + expect(output).toMatchSnapshot(); + }); + + it('returns the expected output when given empty style values', () => { + const output = getMessageStyle({ + body: { style: null }, + container: { style: null }, + header: { style: null }, + primaryButton: { style: null }, + secondaryButton: { style: null }, + } as InAppMessageComponentBaseProps); + + expect(output).toMatchSnapshot(); + }); +}); + +describe('getMessageStyleProps', () => { + it('returns the expected output in the happy path', () => { + const defaultStyle = { + body: { color: 'fuschia' }, + buttonContainer: { backgroundColor: 'chartreuse' }, + buttonText: { color: 'pink' }, + buttonsContainer: { backgroundColor: 'teal' }, + componentWrapper: { backgroundColor: 'gray' }, + contentContainer: { backgroundColor: 'lightblue' }, + container: { backgroundColor: 'red', borderRadius: 1 }, + header: { backgroundColor: 'purple' }, + iconButton: { backgroundColor: 'blue' }, + image: { backgroundColor: 'yellow' }, + imageContainer: { backgroundColor: 'green' }, + textContainer: { backgroundColor: 'antiquewhite' }, + }; + + const messageStyle: StyleParams['messageStyle'] = { + body: { textAlign: 'left' as InAppMessageTextAlign }, + container: { backgroundColor: 'lightgray', borderRadius: 2 }, + header: { textAlign: 'center' as InAppMessageTextAlign }, + primaryButton: { backgroundColor: 'salmon', color: 'olive' }, + secondaryButton: { backgroundColor: 'sand', color: 'peru' }, + }; + + const overrideStyle = { + body: { color: 'white' }, + closeIconButton: { backgroundColor: 'turquoise' }, + closeIconColor: 'darkcyan', + container: { backgroundColor: 'lawngreen', borderRadius: 3 }, + header: { backgroundColor: 'lightpink' }, + image: { backgroundColor: 'royalblue' }, + primaryButton: { container: { backgroundColor: 'seagreen' }, text: { color: 'black' } }, + secondaryButton: { container: { backgroundColor: 'sienna' }, text: { color: 'orchid' } }, + }; + + const output = getMessageStyleProps({ + layout: 'FULL_SCREEN', + styleParams: { defaultStyle, messageStyle, overrideStyle }, + }); + + expect(output).toMatchSnapshot(); + }); + + it('returns the expected output when provided null style params', () => { + const defaultStyle: StyleParams['defaultStyle'] = null; + const messageStyle: StyleParams['messageStyle'] = null; + const overrideStyle: StyleParams['overrideStyle'] = null; + + const output = getMessageStyleProps({ + layout: 'MODAL', + styleParams: { defaultStyle, messageStyle, overrideStyle }, + }); + + expect(output).toMatchSnapshot(); + }); +}); diff --git a/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessageProps/types.ts b/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessageProps/types.ts index 1b9ac466e3b..f7427535440 100644 --- a/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessageProps/types.ts +++ b/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessageProps/types.ts @@ -45,7 +45,7 @@ export type MessageStyleProps = { }; export type GetDefaultStyle = ( - imageDimensions: ImageDimensions, + imageDimensions?: ImageDimensions, additionalStyle?: Record ) => InAppMessageComponentBaseStyle; @@ -86,3 +86,15 @@ export type MessageStylePropParams = { */ styleParams: StyleParams; }; + +export type ButtonStylePropParams = { + /** + * message button types + */ + buttonType: 'primaryButton' | 'secondaryButton'; + + /** + * style params to derive resolved style from + */ + styleParams: StyleParams; +}; diff --git a/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessageProps/useMessageProps.ts b/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessageProps/useMessageProps.ts index cfd236888a9..a59a90bec83 100644 --- a/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessageProps/useMessageProps.ts +++ b/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessageProps/useMessageProps.ts @@ -43,7 +43,7 @@ export default function useMessageProps( useEffect(() => { if (!hasDisplayed.current && shouldRenderMessage) { - onDisplay(); + onDisplay?.(); hasDisplayed.current = true; } }, [onDisplay, shouldRenderMessage]); diff --git a/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessageProps/utils.ts b/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessageProps/utils.ts index 8102b4edd1b..460b10cd0c3 100644 --- a/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessageProps/utils.ts +++ b/packages/aws-amplify-react-native/src/InAppMessaging/components/hooks/useMessageProps/utils.ts @@ -11,35 +11,38 @@ * and limitations under the License. */ -import { StyleProp, StyleSheet, TextStyle, ViewStyle } from 'react-native'; -import { InAppMessageStyle } from '@aws-amplify/notifications'; +import { StyleProp, StyleSheet, ViewStyle } from 'react-native'; import { BUTTON_PRESSED_OPACITY } from '../../constants'; import { InAppMessageComponentBaseProps, InAppMessageComponentButtonStyle } from '../../types'; -import { MessageStylePropParams, MessageStyleProps } from './types'; +import { ButtonStylePropParams, MessageStylePropParams, MessageStyleProps } from './types'; /** * Parse and assign appropriate button container and text style from style objects params * - * @param {defaultButtonStyle} object - default button style specified at the component level - * @param {messageButtonStyle} object - message button style from message payload - * @param {overrideStyle} object - custom style passed to component - * + * @param {params} object - contains message styleParams and button type * @returns {InAppMessageComponentButtonStyle} resolved button container and text style arrays */ -export const getButtonComponentStyle = ( - defaultButtonStyle: { buttonContainer: ViewStyle; buttonText: TextStyle }, - messageButtonStyle: InAppMessageStyle, - overrideStyle: InAppMessageComponentButtonStyle -): InAppMessageComponentButtonStyle => { +export const getComponentButtonStyle = ({ + styleParams, + buttonType, +}: ButtonStylePropParams): InAppMessageComponentButtonStyle => { + const { defaultStyle, messageStyle, overrideStyle } = styleParams; // default component styles defined at the UI component level - const { buttonContainer, buttonText } = defaultButtonStyle; + const { buttonContainer: containerDefaultStyle = {}, buttonText: textDefaultStyle = {} } = defaultStyle ?? {}; // message specific styles in the in-app message payload, overrides default component styles - const { backgroundColor, borderRadius, color } = messageButtonStyle ?? {}; + const { backgroundColor, borderRadius, color } = messageStyle?.[buttonType] ?? {}; + + const containerMessageStyle = { + ...(backgroundColor ? { backgroundColor } : null), + ...(borderRadius ? { borderRadius } : null), + }; + + const textMessageStyle = { ...(color ? { color } : null) }; // custom component override styles passed as style prop, overrides all previous styles - const { container, text } = overrideStyle ?? {}; + const { container: containerOverrideStyle = {}, text: textOverrideStyle = {} } = overrideStyle?.[buttonType] ?? {}; return { // the style prop of the React Native Pressable component used in the message UI accepts either a ViewStyle array @@ -47,14 +50,16 @@ export const getButtonComponentStyle = ( // array. Utilizing the latter, we add an opacity value to the UI message button style during press events container: ({ pressed } = { pressed: false }) => { // default button press interaction opacity - const opacity = pressed ? BUTTON_PRESSED_OPACITY : null; + const pressedOpacity = pressed ? { opacity: BUTTON_PRESSED_OPACITY } : {}; - // pass `pressed` to container and evaluate if the consumer passed a function for custom button style - const finalOverrideContainerStyle = typeof container === 'function' ? container({ pressed }) : container; + // pass `pressed` to containerOverrideStyle and evaluate if the consumer passed a function for custom + // button style + const containerOverrideFinalStyle = + typeof containerOverrideStyle === 'function' ? containerOverrideStyle({ pressed }) : containerOverrideStyle; - return [{ opacity }, buttonContainer, { backgroundColor, borderRadius }, finalOverrideContainerStyle]; + return [pressedOpacity, containerDefaultStyle, containerMessageStyle, containerOverrideFinalStyle]; }, - text: [buttonText, { color }, text], + text: [textDefaultStyle, textMessageStyle, textOverrideStyle], }; }; @@ -68,31 +73,38 @@ export const getButtonComponentStyle = ( export const getContainerAndWrapperStyle = ({ styleParams, layout }: MessageStylePropParams) => { const { defaultStyle, messageStyle, overrideStyle } = styleParams; + const containerDefaultStyle = defaultStyle?.container ?? {}; + const containerMessageStyle = messageStyle?.container ?? {}; + const containerOverrideStyle = overrideStyle?.container ?? {}; + + const wrapperDefaultStyle = defaultStyle?.componentWrapper ?? {}; + // banner layouts requires no special handling of container or wrapper styles if (layout === 'TOP_BANNER' || layout === 'MIDDLE_BANNER' || layout === 'BOTTOM_BANNER') { return { - componentWrapper: defaultStyle.componentWrapper, - container: [defaultStyle.container, messageStyle?.container, overrideStyle?.container], + componentWrapper: wrapperDefaultStyle, + container: [containerDefaultStyle, containerMessageStyle, containerOverrideStyle], }; } - // in non-banner layouts the message and override container backgroundColor values are passed inside - // wrapperStyle to the MessageWrapper to ensure that the is applied to the entire screen - const { container: baseOverrideContainerStyle } = overrideStyle ?? {}; + // in non-banner layouts container backgroundColor values should be applied as componentWrapper style + // to ensure that the backgroundColor is applied to the entire screen + const { backgroundColor: defaultBackgroundColor, ...restContainerDefaultStyle } = containerDefaultStyle; + const { backgroundColor: messageBackgroundColor, ...restContainerMessageStyle } = containerMessageStyle; // flatten overrideStyle to access override backgroundColor - const flattenedOverrideStyle = StyleSheet.flatten(baseOverrideContainerStyle); - const { backgroundColor: overrideBackgroundColor, ...overrideContainerStyle } = flattenedOverrideStyle ?? {}; - const { backgroundColor: messageBackgroundColor, ...messageContainerStyle } = messageStyle?.container; + const { backgroundColor: overrideBackgroundColor, ...restContainerOverrideStyle } = + StyleSheet.flatten(containerOverrideStyle); - // default and all non-backgroundColor container override style are applied to the container View - const container = [defaultStyle.container, messageContainerStyle, overrideContainerStyle]; + // all non-backgroundColor container override style are applied to the container View + const container = [restContainerDefaultStyle, restContainerMessageStyle, restContainerOverrideStyle]; // use ternaries to prevent passing backgroundColor object with undefined or null value const componentWrapper: StyleProp = [ - defaultStyle.componentWrapper, - messageBackgroundColor ? { backgroundColor: messageBackgroundColor } : null, - overrideBackgroundColor ? { backgroundColor: overrideBackgroundColor } : null, + wrapperDefaultStyle, + defaultBackgroundColor ? { backgroundColor: defaultBackgroundColor } : {}, + messageBackgroundColor ? { backgroundColor: messageBackgroundColor } : {}, + overrideBackgroundColor ? { backgroundColor: overrideBackgroundColor } : {}, ]; return { componentWrapper, container }; @@ -102,7 +114,7 @@ export const getContainerAndWrapperStyle = ({ styleParams, layout }: MessageStyl * Utility for extracting message payload style * * @param {props} - message props - * @returns message payload specific style + * @returns {object} - contains message payload specific style */ export const getMessageStyle = ({ @@ -137,35 +149,27 @@ export function getMessageStyleProps({ styleParams, layout }: MessageStylePropPa // view style applied to the componentWrapper and primary container views const { componentWrapper, container } = getContainerAndWrapperStyle({ styleParams, layout }); + // primary and secondary button container and text style + const primaryButton = getComponentButtonStyle({ styleParams, buttonType: 'primaryButton' }); + const secondaryButton = getComponentButtonStyle({ styleParams, buttonType: 'secondaryButton' }); + const { defaultStyle, messageStyle, overrideStyle } = styleParams; // image style composed of default and override style - const image = [defaultStyle.image, overrideStyle?.image]; + const image = [defaultStyle?.image, overrideStyle?.image]; const iconButton = { // view style applied to icon button - container: [defaultStyle.iconButton, overrideStyle?.closeIconButton], + container: [defaultStyle?.iconButton, overrideStyle?.closeIconButton], // close icon color, only specified as an overrideStyle iconColor: overrideStyle?.closeIconColor, }; // text style applied to message body and header respectively - const body = [defaultStyle.body, messageStyle?.body, overrideStyle?.body]; - const header = [defaultStyle.header, messageStyle?.header, overrideStyle?.header]; + const body = [defaultStyle?.body, messageStyle?.body, overrideStyle?.body]; + const header = [defaultStyle?.header, messageStyle?.header, overrideStyle?.header]; - // primary and secondary button container and text style - const primaryButton = getButtonComponentStyle( - defaultStyle, - messageStyle?.primaryButton, - overrideStyle?.primaryButton - ); - const secondaryButton = getButtonComponentStyle( - defaultStyle, - messageStyle?.secondaryButton, - overrideStyle?.secondaryButton - ); - - const { buttonsContainer, contentContainer, imageContainer, textContainer } = defaultStyle; + const { buttonsContainer, contentContainer, imageContainer, textContainer } = defaultStyle ?? {}; return { body, diff --git a/packages/aws-amplify-react-native/src/__mocks__/@aws-amplify/core.ts b/packages/aws-amplify-react-native/src/__mocks__/@aws-amplify/core.ts new file mode 100644 index 00000000000..c4fa0a8b5a0 --- /dev/null +++ b/packages/aws-amplify-react-native/src/__mocks__/@aws-amplify/core.ts @@ -0,0 +1,42 @@ +/* + * Copyright 2017-2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance with + * the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR + * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ + +// export unmocked core modules +export * from '@aws-amplify/core'; + +// mock log functions +const mockDebug = jest.fn(); +const mockError = jest.fn(); +const mockInfo = jest.fn(); +const mockWarn = jest.fn(); +const mockLog = jest.fn(); + +const mockVerbose = jest.fn(); +const mockAddPluggable = jest.fn(); +const mockListPluggables = jest.fn(); + +// mock logger +const ConsoleLogger = jest.fn().mockImplementation(() => { + return { + addPluggable: mockAddPluggable, + debug: mockDebug, + error: mockError, + info: mockInfo, + listPluggables: mockListPluggables, + log: mockLog, + verbose: mockVerbose, + warn: mockWarn, + }; +}); + +export { ConsoleLogger };