From 22977ddbc0609c8f0e43b7ac2c4d1e68390144a0 Mon Sep 17 00:00:00 2001 From: Caleb Pollman Date: Thu, 4 Jan 2024 13:03:31 -0800 Subject: [PATCH] feat(A.Next): add useQRCodeString for RN --- packages/react-native-auth/jest.config.ts | 15 +- packages/react-native-auth/package.json | 3 +- .../Authenticator/Authenticator.tsx | 0 .../{ => components}/Authenticator/index.ts | 0 .../react-native-auth/src/components/index.ts | 1 + packages/react-native-auth/src/hooks/index.ts | 1 + .../useQRCodeString.spec.ts.snap | 37 ++++ .../__tests__/useQRCodeString.spec.ts | 200 ++++++++++++++++++ .../src/hooks/useQRCodeString/index.ts | 1 + .../hooks/useQRCodeString/useQRCodeString.ts | 76 +++++++ packages/react-native-auth/src/index.ts | 2 +- packages/react-native/jest.config.ts | 3 +- 12 files changed, 334 insertions(+), 5 deletions(-) rename packages/react-native-auth/src/{ => components}/Authenticator/Authenticator.tsx (100%) rename packages/react-native-auth/src/{ => components}/Authenticator/index.ts (100%) create mode 100644 packages/react-native-auth/src/components/index.ts create mode 100644 packages/react-native-auth/src/hooks/index.ts create mode 100644 packages/react-native-auth/src/hooks/useQRCodeString/__tests__/__snapshots__/useQRCodeString.spec.ts.snap create mode 100644 packages/react-native-auth/src/hooks/useQRCodeString/__tests__/useQRCodeString.spec.ts create mode 100644 packages/react-native-auth/src/hooks/useQRCodeString/index.ts create mode 100644 packages/react-native-auth/src/hooks/useQRCodeString/useQRCodeString.ts diff --git a/packages/react-native-auth/jest.config.ts b/packages/react-native-auth/jest.config.ts index 0e6ba48a915..bd03175a50a 100644 --- a/packages/react-native-auth/jest.config.ts +++ b/packages/react-native-auth/jest.config.ts @@ -3,13 +3,24 @@ import { Config } from 'jest'; const config: Config = { preset: 'react-native', modulePathIgnorePatterns: ['/dist/'], + collectCoverage: true, collectCoverageFrom: [ '/src/**/*.{js,jsx,ts,tsx}', - '!/src/**/*{c,C}onstants.ts', + // exclude top level version.ts + '!/src/version.ts', ], moduleNameMapper: { - '^react$': '/node_modules/react', '^react-native$': '/node_modules/react-native', + '^uuid$': '/../../node_modules/uuid', + }, + modulePaths: ['/node_modules/'], + coverageThreshold: { + global: { + branches: 90, + functions: 90, + lines: 90, + statements: 90, + }, }, setupFiles: ['/jest.setup.ts'], }; diff --git a/packages/react-native-auth/package.json b/packages/react-native-auth/package.json index bd64ab436b3..64ca37f8b4a 100644 --- a/packages/react-native-auth/package.json +++ b/packages/react-native-auth/package.json @@ -30,7 +30,8 @@ "dependencies": { "@aws-amplify/ui": "6.0.6", "@aws-amplify/ui-react-core-auth": "0.0.8", - "@aws-amplify/ui-react-native": "2.0.7" + "@aws-amplify/ui-react-native": "2.0.7", + "qrcode": "1.5.0" }, "peerDependencies": { "aws-amplify": "^6.0.2", diff --git a/packages/react-native-auth/src/Authenticator/Authenticator.tsx b/packages/react-native-auth/src/components/Authenticator/Authenticator.tsx similarity index 100% rename from packages/react-native-auth/src/Authenticator/Authenticator.tsx rename to packages/react-native-auth/src/components/Authenticator/Authenticator.tsx diff --git a/packages/react-native-auth/src/Authenticator/index.ts b/packages/react-native-auth/src/components/Authenticator/index.ts similarity index 100% rename from packages/react-native-auth/src/Authenticator/index.ts rename to packages/react-native-auth/src/components/Authenticator/index.ts diff --git a/packages/react-native-auth/src/components/index.ts b/packages/react-native-auth/src/components/index.ts new file mode 100644 index 00000000000..293f769bab4 --- /dev/null +++ b/packages/react-native-auth/src/components/index.ts @@ -0,0 +1 @@ +export { Authenticator } from './Authenticator'; diff --git a/packages/react-native-auth/src/hooks/index.ts b/packages/react-native-auth/src/hooks/index.ts new file mode 100644 index 00000000000..0a5a46e950b --- /dev/null +++ b/packages/react-native-auth/src/hooks/index.ts @@ -0,0 +1 @@ +export { useQRCodeString } from './useQRCodeString'; diff --git a/packages/react-native-auth/src/hooks/useQRCodeString/__tests__/__snapshots__/useQRCodeString.spec.ts.snap b/packages/react-native-auth/src/hooks/useQRCodeString/__tests__/__snapshots__/useQRCodeString.spec.ts.snap new file mode 100644 index 00000000000..f69541eec97 --- /dev/null +++ b/packages/react-native-auth/src/hooks/useQRCodeString/__tests__/__snapshots__/useQRCodeString.spec.ts.snap @@ -0,0 +1,37 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`useQRCodeString behaves as expected in the happy path 1`] = ` +" + + █▀▀▀▀▀█ ▀██▀▀ █▀▀▀▀▀█ + █ ███ █ ▄▀▀█▀ █ ███ █ + █ ▀▀▀ █ █ ▄▀ █ ▀▀▀ █ + ▀▀▀▀▀▀▀ █ ▀▄█ ▀▀▀▀▀▀▀ + ▀▄ █ ▀▀█▄██▄▀▀█▀█▄▄▀ + ▀ ▀ █▀▀ ██▀▀ ▄█▀█ ▀ + ▀ ▀ ▀▀▄▄█ ███ ▀▄ ▀ + █▀▀▀▀▀█ ▀▄▀▄█▀ ▄ ▀█ + █ ███ █ ▀▄ █▄ ▀▀▀█▄▄█ + █ ▀▀▀ █ ▄█▀ ▄█▀█▀ + ▀▀▀▀▀▀▀ ▀ ▀ ▀▀▀ ▀▀ ▀ + + " +`; + +exports[`useQRCodeString does not call \`onSuccess\` if it is not a function 1`] = ` +" + + █▀▀▀▀▀█ ▀██▀▀ █▀▀▀▀▀█ + █ ███ █ ▄▀▀█▀ █ ███ █ + █ ▀▀▀ █ █ ▄▀ █ ▀▀▀ █ + ▀▀▀▀▀▀▀ █ ▀▄█ ▀▀▀▀▀▀▀ + ▀▄ █ ▀▀█▄██▄▀▀█▀█▄▄▀ + ▀ ▀ █▀▀ ██▀▀ ▄█▀█ ▀ + ▀ ▀ ▀▀▄▄█ ███ ▀▄ ▀ + █▀▀▀▀▀█ ▀▄▀▄█▀ ▄ ▀█ + █ ███ █ ▀▄ █▄ ▀▀▀█▄▄█ + █ ▀▀▀ █ ▄█▀ ▄█▀█▀ + ▀▀▀▀▀▀▀ ▀ ▀ ▀▀▀ ▀▀ ▀ + + " +`; diff --git a/packages/react-native-auth/src/hooks/useQRCodeString/__tests__/useQRCodeString.spec.ts b/packages/react-native-auth/src/hooks/useQRCodeString/__tests__/useQRCodeString.spec.ts new file mode 100644 index 00000000000..8c00cf762c6 --- /dev/null +++ b/packages/react-native-auth/src/hooks/useQRCodeString/__tests__/useQRCodeString.spec.ts @@ -0,0 +1,200 @@ +import { renderHook } from '@testing-library/react-hooks'; +import QRCodeModule from 'qrcode'; + +import { useQRCodeString, UseQRCodeStringParams } from '../useQRCodeString'; + +const toStringSpy = jest.spyOn(QRCodeModule, 'toString'); +const TEST_STRING = 'testy'; + +const onError = jest.fn(); +const onSuccess = jest.fn(); + +const BASE_INPUT = { text: TEST_STRING, onError, onSuccess }; +const INVALID_INPUT = { + text: TEST_STRING, + onError: 'not a function', + onSuccess: 'also not a function', +}; + +describe('useQRCodeString', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('behaves as expected in the happy path', async () => { + const { waitForNextUpdate, result } = renderHook( + (input: UseQRCodeStringParams = BASE_INPUT) => useQRCodeString(input) + ); + + expect(result.current.isLoading).toBe(true); + expect(result.current.hasError).toBe(false); + expect(result.current.qrCodeString).toBeNull(); + + await waitForNextUpdate(); + + expect(result.current.isLoading).toBe(false); + expect(result.current.hasError).toBe(false); + expect(result.current.qrCodeString).toMatchSnapshot(); + + expect(onSuccess).toHaveBeenCalledTimes(1); + expect(onSuccess).toHaveBeenCalledWith(result.current.qrCodeString); + }); + + it('behaves as expected when `toString` throws an error', async () => { + const error = new Error('Rejected!'); + + (toStringSpy as jest.Mock).mockRejectedValueOnce(error); + + const { waitForNextUpdate, result } = renderHook(() => + useQRCodeString(BASE_INPUT) + ); + + expect(result.current.isLoading).toBe(true); + expect(result.current.hasError).toBe(false); + expect(result.current.qrCodeString).toBeNull(); + + await waitForNextUpdate(); + + expect(result.current.isLoading).toBe(false); + expect(result.current.hasError).toBe(true); + expect(result.current.qrCodeString).toBeNull(); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(error.message); + }); + + it('does not call `toString` when `text` param is not provided', () => { + const { result } = renderHook(useQRCodeString); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasError).toBe(false); + expect(result.current.qrCodeString).toBeNull(); + }); + + it('ignores the first response if rerun a second time before the first call resolves in the happy path', async () => { + const firstResponse = 'first response'; + const secondResponse = 'second response'; + + (toStringSpy as jest.Mock) + .mockResolvedValueOnce(firstResponse) + .mockResolvedValueOnce(secondResponse); + + const { waitForNextUpdate, result, rerender } = renderHook( + (input: UseQRCodeStringParams = BASE_INPUT) => useQRCodeString(input) + ); + + expect(result.current.isLoading).toBe(true); + expect(result.current.hasError).toBe(false); + expect(result.current.qrCodeString).toBeNull(); + + rerender({ ...BASE_INPUT, text: 'new value' }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.hasError).toBe(false); + expect(result.current.qrCodeString).toBeNull(); + + await waitForNextUpdate(); + + expect(toStringSpy).toHaveBeenCalledTimes(2); + + expect(result.current.isLoading).toBe(false); + expect(result.current.hasError).toBe(false); + expect(result.current.qrCodeString).toBe(secondResponse); + + expect(onSuccess).toHaveBeenCalledTimes(1); + expect(onSuccess).toHaveBeenCalledWith(secondResponse); + }); + + it('ignores the first response if rerun a second time before the first call resolves in the unhappy path', async () => { + const firstError = new Error('first response'); + const secondError = new Error('second response'); + + (toStringSpy as jest.Mock) + .mockRejectedValueOnce(firstError) + .mockRejectedValueOnce(secondError); + + const { waitForNextUpdate, result, rerender } = renderHook( + (input: UseQRCodeStringParams = BASE_INPUT) => useQRCodeString(input) + ); + + expect(result.current.isLoading).toBe(true); + expect(result.current.hasError).toBe(false); + expect(result.current.qrCodeString).toBeNull(); + + rerender({ ...BASE_INPUT, text: 'new value' }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.hasError).toBe(false); + expect(result.current.qrCodeString).toBeNull(); + + await waitForNextUpdate(); + + expect(toStringSpy).toHaveBeenCalledTimes(2); + + expect(result.current.isLoading).toBe(false); + expect(result.current.hasError).toBe(true); + expect(result.current.qrCodeString).toBeNull(); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(secondError.message); + }); + + it('calls `toString` when `text` param changes', async () => { + const { rerender, waitForNextUpdate } = renderHook( + (input: UseQRCodeStringParams = BASE_INPUT) => useQRCodeString(input) + ); + + await waitForNextUpdate(); + + expect(toStringSpy).toHaveBeenCalledTimes(1); + + // rerender with same input + rerender(BASE_INPUT); + + expect(toStringSpy).toHaveBeenCalledTimes(1); + + // rerender with new `text` param + rerender({ ...BASE_INPUT, text: 'new value' }); + + await waitForNextUpdate(); + + expect(toStringSpy).toHaveBeenCalledTimes(2); + }); + + it('does not call `onSuccess` if it is not a function', async () => { + const { waitForNextUpdate, result } = renderHook( + // @ts-expect-error test against invalid input + () => useQRCodeString(INVALID_INPUT) + ); + + expect(result.current.isLoading).toBe(true); + expect(result.current.hasError).toBe(false); + expect(result.current.qrCodeString).toBeNull(); + + await waitForNextUpdate(); + + expect(result.current.isLoading).toBe(false); + expect(result.current.hasError).toBe(false); + expect(result.current.qrCodeString).toMatchSnapshot(); + }); + + it('does not call `onError` if it is not a function', async () => { + const error = new Error('Rejected!'); + + (toStringSpy as jest.Mock).mockRejectedValueOnce(error); + + const { waitForNextUpdate, result } = renderHook( + // @ts-expect-error test against invalid input + () => useQRCodeString(INVALID_INPUT) + ); + + expect(result.current.isLoading).toBe(true); + expect(result.current.hasError).toBe(false); + expect(result.current.qrCodeString).toBeNull(); + + await waitForNextUpdate(); + + expect(result.current.isLoading).toBe(false); + expect(result.current.hasError).toBe(true); + expect(result.current.qrCodeString).toBeNull(); + }); +}); diff --git a/packages/react-native-auth/src/hooks/useQRCodeString/index.ts b/packages/react-native-auth/src/hooks/useQRCodeString/index.ts new file mode 100644 index 00000000000..0a5a46e950b --- /dev/null +++ b/packages/react-native-auth/src/hooks/useQRCodeString/index.ts @@ -0,0 +1 @@ +export { useQRCodeString } from './useQRCodeString'; diff --git a/packages/react-native-auth/src/hooks/useQRCodeString/useQRCodeString.ts b/packages/react-native-auth/src/hooks/useQRCodeString/useQRCodeString.ts new file mode 100644 index 00000000000..c376d54719f --- /dev/null +++ b/packages/react-native-auth/src/hooks/useQRCodeString/useQRCodeString.ts @@ -0,0 +1,76 @@ +import React from 'react'; +import { toString as toQRCodeString, QRCodeToStringOptions } from 'qrcode'; + +import { isFunction } from '@aws-amplify/ui'; + +export type UseQRCodeStringParams = { + onError?: (err: string) => void; + onSuccess?: (output: string) => void; + text?: string; + options?: QRCodeToStringOptions; +}; + +interface UseQRCodeString { + hasError: boolean; + isLoading: boolean; + qrCodeString: string | null; +} + +interface UseQRCodeState + extends Pick {} + +const INITIAL_OUTPUT: UseQRCodeState = { hasError: false, qrCodeString: null }; +const ERROR_OUTPUT: UseQRCodeState = { hasError: true, qrCodeString: null }; + +/** + * Generates a QR code string from provided `text` param + * + * @param {UseQRCodeStringParams} params target text and event callbacks + * @returns {UseQRCodeString} QR code string and related values + */ +export function useQRCodeString( + params?: UseQRCodeStringParams +): UseQRCodeString { + const { onError, onSuccess, text, options } = params ?? {}; + const [{ hasError, qrCodeString }, setOutput] = + React.useState(() => INITIAL_OUTPUT); + + // only true when a `text` param has been provided and + // both `qrCodeString` and `hasError` are falsy + const isLoading = !!(text && !qrCodeString && !hasError); + + React.useEffect(() => { + if (!text) { + return; + } + let ignore = false; + + toQRCodeString(text, options) + .then((_qrCodeString) => { + if (ignore) { + return; + } + + if (isFunction(onSuccess)) { + onSuccess(_qrCodeString); + } + setOutput({ hasError: false, qrCodeString: _qrCodeString }); + }) + .catch((error) => { + if (ignore) { + return; + } + + if (isFunction(onError)) { + onError((error as Error).message); + } + setOutput(ERROR_OUTPUT); + }); + + return () => { + ignore = true; + }; + }, [onError, onSuccess, options, text]); + + return { hasError, isLoading, qrCodeString }; +} diff --git a/packages/react-native-auth/src/index.ts b/packages/react-native-auth/src/index.ts index 293f769bab4..19bbdb69b45 100644 --- a/packages/react-native-auth/src/index.ts +++ b/packages/react-native-auth/src/index.ts @@ -1 +1 @@ -export { Authenticator } from './Authenticator'; +export { Authenticator } from './components'; diff --git a/packages/react-native/jest.config.ts b/packages/react-native/jest.config.ts index f81b4281dcf..bd03175a50a 100644 --- a/packages/react-native/jest.config.ts +++ b/packages/react-native/jest.config.ts @@ -6,7 +6,8 @@ const config: Config = { collectCoverage: true, collectCoverageFrom: [ '/src/**/*.{js,jsx,ts,tsx}', - '!/src/**/*{c,C}onstants.ts', + // exclude top level version.ts + '!/src/version.ts', ], moduleNameMapper: { '^react-native$': '/node_modules/react-native',