Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(A.Next): add useQRCodeString for RN #4886

Merged
merged 1 commit into from
Jan 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions packages/react-native-auth/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,24 @@ import { Config } from 'jest';
const config: Config = {
preset: 'react-native',
modulePathIgnorePatterns: ['<rootDir>/dist/'],
collectCoverage: true,
collectCoverageFrom: [
'<rootDir>/src/**/*.{js,jsx,ts,tsx}',
'!<rootDir>/src/**/*{c,C}onstants.ts',
// exclude top level version.ts
'!<rootDir>/src/version.ts',
],
moduleNameMapper: {
'^react$': '<rootDir>/node_modules/react',
'^react-native$': '<rootDir>/node_modules/react-native',
'^uuid$': '<rootDir>/../../node_modules/uuid',
},
modulePaths: ['<rootDir>/node_modules/'],
coverageThreshold: {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice!

global: {
branches: 90,
functions: 90,
lines: 90,
statements: 90,
},
},
setupFiles: ['<rootDir>/jest.setup.ts'],
};
Expand Down
3 changes: 2 additions & 1 deletion packages/react-native-auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/react-native-auth/src/components/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { Authenticator } from './Authenticator';
1 change: 1 addition & 0 deletions packages/react-native-auth/src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { useQRCodeString } from './useQRCodeString';
Original file line number Diff line number Diff line change
@@ -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`] = `
"

█▀▀▀▀▀█ ▀██▀▀ █▀▀▀▀▀█
reesscot marked this conversation as resolved.
Show resolved Hide resolved
█ ███ █ ▄▀▀█▀ █ ███ █
█ ▀▀▀ █ █ ▄▀ █ ▀▀▀ █
▀▀▀▀▀▀▀ █ ▀▄█ ▀▀▀▀▀▀▀
▀▄ █ ▀▀█▄██▄▀▀█▀█▄▄▀
▀ ▀ █▀▀ ██▀▀ ▄█▀█ ▀
▀ ▀ ▀▀▄▄█ ███ ▀▄ ▀
█▀▀▀▀▀█ ▀▄▀▄█▀ ▄ ▀█
█ ███ █ ▀▄ █▄ ▀▀▀█▄▄█
█ ▀▀▀ █ ▄█▀ ▄█▀█▀
▀▀▀▀▀▀▀ ▀ ▀ ▀▀▀ ▀▀ ▀

"
`;
Original file line number Diff line number Diff line change
@@ -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();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { useQRCodeString } from './useQRCodeString';
Original file line number Diff line number Diff line change
@@ -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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have a general pattern or philosophy on whether it should be onError?: (error: Error) => void; or onError?: (err: string) => void;?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question 😄 We do not, for internal hooks personally have been leaning towards (errorMessage: string) => void for ease of use in client code but don't feel particularly strongly either way

onSuccess?: (output: string) => void;
text?: string;
options?: QRCodeToStringOptions;
};

interface UseQRCodeString {
hasError: boolean;
isLoading: boolean;
qrCodeString: string | null;
}

interface UseQRCodeState
extends Pick<UseQRCodeString, 'hasError' | 'qrCodeString'> {}

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<UseQRCodeState>(() => 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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this because the hook can be unmounted before the async toQRCodeString completes?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep 😅

};
}, [onError, onSuccess, options, text]);

return { hasError, isLoading, qrCodeString };
}
2 changes: 1 addition & 1 deletion packages/react-native-auth/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { Authenticator } from './Authenticator';
export { Authenticator } from './components';
3 changes: 2 additions & 1 deletion packages/react-native/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ const config: Config = {
collectCoverage: true,
collectCoverageFrom: [
'<rootDir>/src/**/*.{js,jsx,ts,tsx}',
'!<rootDir>/src/**/*{c,C}onstants.ts',
// exclude top level version.ts
'!<rootDir>/src/version.ts',
],
moduleNameMapper: {
'^react-native$': '<rootDir>/node_modules/react-native',
Expand Down
Loading