diff --git a/src/id-verification/CollapsibleImageHelp.jsx b/src/id-verification/CollapsibleImageHelp.jsx new file mode 100644 index 0000000..cdb4883 --- /dev/null +++ b/src/id-verification/CollapsibleImageHelp.jsx @@ -0,0 +1,66 @@ +import React, { useContext } from 'react'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { Button, Collapsible } from '@edx/paragon'; +import { Link } from 'react-router-dom'; +import PropTypes from 'prop-types'; + +import IdVerificationContext, { MEDIA_ACCESS } from './IdVerificationContext'; +import messages from './IdVerification.messages'; + +function CollapsibleImageHelp(props) { + const { + shouldUseCamera, setShouldUseCamera, optimizelyExperimentName, mediaAccess, + } = useContext(IdVerificationContext); + + function handleClick() { + setShouldUseCamera(!shouldUseCamera); + } + + if (optimizelyExperimentName && mediaAccess !== MEDIA_ACCESS.DENIED && mediaAccess !== MEDIA_ACCESS.UNSUPPORTED) { + return ( + +

+ {shouldUseCamera + ? props.intl.formatMessage(messages['id.verification.photo.upload.help.text']) + : props.intl.formatMessage(messages['id.verification.photo.camera.help.text'])} +

+ { (mediaAccess === MEDIA_ACCESS.PENDING && !shouldUseCamera) + ? ( + // if a user has not enabled camera access yet, and they are trying to switch + // to camera mode, direct them to panel that requests camera access + + {props.intl.formatMessage(messages['id.verification.photo.camera.help.button'])} + + ) + : ( + + )} +
+ ); + } + + return null; +} + +CollapsibleImageHelp.propTypes = { + intl: intlShape.isRequired, + isPortrait: PropTypes.bool.isRequired, +}; + +export default injectIntl(CollapsibleImageHelp); diff --git a/src/id-verification/IdVerification.messages.js b/src/id-verification/IdVerification.messages.js index 823f781..0a42a02 100644 --- a/src/id-verification/IdVerification.messages.js +++ b/src/id-verification/IdVerification.messages.js @@ -641,6 +641,56 @@ const messages = defineMessages({ defaultMessage: 'Return to Course', description: 'Return to the course which ID verification was accessed from.', }, + 'id.verification.photo.upload.help.title': { + id: 'id.verification.photo.upload.help.title', + defaultMessage: 'Upload a Photo Instead', + description: 'Title for section that allows switching to photo upload mode.', + }, + 'id.verification.photo.camera.help.title': { + id: 'id.verification.photo.camera.help.title', + defaultMessage: 'Use Your Camera Instead', + description: 'Title for section that allows switching to camera mode.', + }, + 'id.verification.photo.upload.help.text': { + id: 'id.verification.photo.upload.help.text', + defaultMessage: 'If you are having trouble using the photo capture above, you may want to upload a photo instead. To upload a photo, click the button below.', + description: 'Help text for switching to upload mode.', + }, + 'id.verification.photo.camera.help.text': { + id: 'id.verification.photo.camera.help.text', + defaultMessage: 'If you are having trouble uploading a photo above, you may want to use your camera instead. To use your camera, click the button below.', + description: 'Help text for switching to camera mode.', + }, + 'id.verification.photo.upload.help.button': { + id: 'id.verification.upload.help.button', + defaultMessage: 'Switch to Upload Mode', + description: 'Button used to switch to upload mode.', + }, + 'id.verification.photo.camera.help.button': { + id: 'id.verification.camera.help.button', + defaultMessage: 'Switch to Camera Mode', + description: 'Button used to switch to camera mode.', + }, + 'id.verification.choose.mode.title': { + id: 'id.verification.choose.mode.title', + defaultMessage: 'Photo Requirements Options', + description: 'Title for section that allows user to choose photo mode.', + }, + 'id.verification.choose.mode.help.text': { + id: 'id.verification.choose.mode.hep.text', + defaultMessage: 'To complete verification, please select one of the following options to submit photos. You will be able to switch between these options throughout the process if needed.', + description: 'Help text for section that allows user to choose photo mode.', + }, + 'id.verification.choose.mode.radio.upload': { + id: 'id.verification.choose.mode.radio.upload', + defaultMessage: 'Upload photos from my device', + description: 'Radio button to choose to upload photos.', + }, + 'id.verification.choose.mode.radio.camera': { + id: 'id.verification.choose.mode.radio.camera', + defaultMessage: 'Take pictures using my camera', + description: 'Radio button to choose to use camera for photos.', + }, }); export default messages; diff --git a/src/id-verification/IdVerificationContextProvider.jsx b/src/id-verification/IdVerificationContextProvider.jsx index fa28fa2..52b1f55 100644 --- a/src/id-verification/IdVerificationContextProvider.jsx +++ b/src/id-verification/IdVerificationContextProvider.jsx @@ -77,6 +77,7 @@ export default function IdVerificationContextProvider({ children }) { }, [authenticatedUser]); const [optimizelyExperimentName, setOptimizelyExperimentName] = useState(''); + const [shouldUseCamera, setShouldUseCamera] = useState(false); const contextValue = { existingIdVerification, @@ -89,16 +90,19 @@ export default function IdVerificationContextProvider({ children }) { nameOnAccount: authenticatedUser.name, profileDataManager, optimizelyExperimentName, + shouldUseCamera, setExistingIdVerification, setFacePhotoFile, setIdPhotoFile, setIdPhotoName, setOptimizelyExperimentName, + setShouldUseCamera, tryGetUserMedia: async () => { try { const stream = await navigator.mediaDevices.getUserMedia({ video: true }); setMediaAccess(MEDIA_ACCESS.GRANTED); setMediaStream(stream); + setShouldUseCamera(true); // stop the stream, as we are not using it yet const tracks = stream.getTracks(); tracks.forEach(track => track.stop()); diff --git a/src/id-verification/IdVerificationPage.jsx b/src/id-verification/IdVerificationPage.jsx index 278d920..6a44f9e 100644 --- a/src/id-verification/IdVerificationPage.jsx +++ b/src/id-verification/IdVerificationPage.jsx @@ -11,6 +11,7 @@ import './getUserMediaShim'; import IdVerificationContextProvider from './IdVerificationContextProvider'; import ReviewRequirementsPanel from './panels/ReviewRequirementsPanel'; +import ChooseModePanel from './panels/ChooseModePanel'; import RequestCameraAccessPanel from './panels/RequestCameraAccessPanel'; import PortraitPhotoContextPanel from './panels/PortraitPhotoContextPanel'; import TakePortraitPhotoPanel from './panels/TakePortraitPhotoPanel'; @@ -52,6 +53,7 @@ function IdVerificationPage(props) { + diff --git a/src/id-verification/ImageFileUpload.jsx b/src/id-verification/ImageFileUpload.jsx index e4fc982..2f966a4 100644 --- a/src/id-verification/ImageFileUpload.jsx +++ b/src/id-verification/ImageFileUpload.jsx @@ -27,7 +27,7 @@ export default function ImageFileUpload({ onFileChange, intl }) { <> diff --git a/src/id-verification/panels/ChooseModePanel.jsx b/src/id-verification/panels/ChooseModePanel.jsx new file mode 100644 index 0000000..d9bfb7f --- /dev/null +++ b/src/id-verification/panels/ChooseModePanel.jsx @@ -0,0 +1,60 @@ +import React, { useContext } from 'react'; +import { Link } from 'react-router-dom'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { Form } from '@edx/paragon'; + +import { useNextPanelSlug } from '../routing-utilities'; +import BasePanel from './BasePanel'; +import IdVerificationContext from '../IdVerificationContext'; +import messages from '../IdVerification.messages'; + +function ChooseModePanel(props) { + const panelSlug = 'choose-mode'; + const { shouldUseCamera, setShouldUseCamera } = useContext(IdVerificationContext); + + function onPhotoModeChange(value) { + setShouldUseCamera(value); + } + + return ( + +

+ {props.intl.formatMessage(messages['id.verification.choose.mode.help.text'])} +

+
+ + onPhotoModeChange(false)} + /> + onPhotoModeChange(true)} + /> + +
+
+ + {props.intl.formatMessage(messages['id.verification.next'])} + +
+
+ ); +} + +ChooseModePanel.propTypes = { + intl: intlShape.isRequired, +}; + +export default injectIntl(ChooseModePanel); diff --git a/src/id-verification/panels/TakeIdPhotoPanel.jsx b/src/id-verification/panels/TakeIdPhotoPanel.jsx index 022fc3b..63549b5 100644 --- a/src/id-verification/panels/TakeIdPhotoPanel.jsx +++ b/src/id-verification/panels/TakeIdPhotoPanel.jsx @@ -9,24 +9,43 @@ import IdVerificationContext from '../IdVerificationContext'; import messages from '../IdVerification.messages'; import CameraHelp from '../CameraHelp'; +import ImagePreview from '../ImagePreview'; +import ImageFileUpload from '../ImageFileUpload'; +import CollapsibleImageHelp from '../CollapsibleImageHelp'; function TakeIdPhotoPanel(props) { const panelSlug = 'take-id-photo'; const nextPanelSlug = useNextPanelSlug(panelSlug); - const { setIdPhotoFile, idPhotoFile } = useContext(IdVerificationContext); + const { + setIdPhotoFile, idPhotoFile, optimizelyExperimentName, shouldUseCamera, + } = useContext(IdVerificationContext); return (
-

- {props.intl.formatMessage(messages['id.verification.id.photo.instructions.camera'])} -

- + {idPhotoFile && !shouldUseCamera && } + + {shouldUseCamera ? ( +
+

+ {props.intl.formatMessage(messages['id.verification.id.photo.instructions.camera'])} +

+ +
+ ) : ( +
+

+ {props.intl.formatMessage(messages['id.verification.id.photo.instructions.upload'])} +

+ +
+ )}
- + {shouldUseCamera && !optimizelyExperimentName && } +
{props.intl.formatMessage(messages['id.verification.next'])} diff --git a/src/id-verification/panels/TakePortraitPhotoPanel.jsx b/src/id-verification/panels/TakePortraitPhotoPanel.jsx index 7e85c6b..f1d073a 100644 --- a/src/id-verification/panels/TakePortraitPhotoPanel.jsx +++ b/src/id-verification/panels/TakePortraitPhotoPanel.jsx @@ -11,14 +11,14 @@ import CameraHelp from '../CameraHelp'; import IdVerificationContext from '../IdVerificationContext'; import messages from '../IdVerification.messages'; +import CollapsibleImageHelp from '../CollapsibleImageHelp'; function TakePortraitPhotoPanel(props) { const panelSlug = 'take-portrait-photo'; const nextPanelSlug = useNextPanelSlug(panelSlug); - const { setFacePhotoFile, facePhotoFile } = useContext(IdVerificationContext); - const shouldUseCamera = true; - // to reenable upload component: - // const shouldUseCamera = mediaAccess === MEDIA_ACCESS.GRANTED; + const { + setFacePhotoFile, facePhotoFile, shouldUseCamera, optimizelyExperimentName, + } = useContext(IdVerificationContext); return (
) : ( -
-

+

+

{props.intl.formatMessage(messages['id.verification.portrait.photo.instructions.upload'])}

- +
)}
- {shouldUseCamera && } + {shouldUseCamera && !optimizelyExperimentName && } +
{props.intl.formatMessage(messages['id.verification.next'])} diff --git a/src/id-verification/routing-utilities.js b/src/id-verification/routing-utilities.js index 1023600..17d0d59 100644 --- a/src/id-verification/routing-utilities.js +++ b/src/id-verification/routing-utilities.js @@ -4,6 +4,7 @@ import IdVerificationContext from './IdVerificationContext'; const panelSteps = [ 'review-requirements', + 'choose-mode', 'request-camera-access', 'portrait-photo-context', 'take-portrait-photo', @@ -19,9 +20,28 @@ export const useNextPanelSlug = (originSlug) => { // Go back to the summary view if that's where they came from const location = useLocation(); const isFromSummary = location.state && location.state.fromSummary; + const isFromPortrait = location.state && location.state.fromPortraitCapture; + const isFromId = location.state && location.state.fromIdCapture; + const { shouldUseCamera, optimizelyExperimentName } = useContext(IdVerificationContext); + if (isFromSummary) { return 'summary'; } + if (isFromPortrait) { + return 'portrait-photo-context'; + } + if (isFromId) { + return 'id-context'; + } + if (originSlug === 'review-requirements' && !optimizelyExperimentName) { + return 'request-camera-access'; + } + if (originSlug === 'choose-mode' && !shouldUseCamera) { + return 'take-portrait-photo'; + } + if (originSlug === 'take-portrait-photo' && !shouldUseCamera) { + return 'take-id-photo'; + } const nextIndex = panelSteps.indexOf(originSlug) + 1; return nextIndex < panelSteps.length ? panelSteps[nextIndex] : null; @@ -30,9 +50,11 @@ export const useNextPanelSlug = (originSlug) => { // check if the user is too far into the flow and if so, return the slug of the // furthest panel they are allow to be. export const useVerificationRedirectSlug = (slug) => { - const { facePhotoFile, idPhotoFile } = useContext(IdVerificationContext); + const { facePhotoFile, idPhotoFile, optimizelyExperimentName } = useContext(IdVerificationContext); const indexOfCurrentPanel = panelSteps.indexOf(slug); - + if (!optimizelyExperimentName && slug === 'choose-mode') { + return 'review-requirements'; + } if (!facePhotoFile) { if (indexOfCurrentPanel > panelSteps.indexOf('take-portrait-photo')) { return 'portrait-photo-context'; diff --git a/src/id-verification/tests/CollapsibleImageHelp.test.jsx b/src/id-verification/tests/CollapsibleImageHelp.test.jsx new file mode 100644 index 0000000..af54482 --- /dev/null +++ b/src/id-verification/tests/CollapsibleImageHelp.test.jsx @@ -0,0 +1,153 @@ +import React from 'react'; +import { Router } from 'react-router-dom'; +import { createMemoryHistory } from 'history'; +import '@testing-library/jest-dom/extend-expect'; +import { + render, cleanup, screen, act, fireEvent, +} from '@testing-library/react'; +import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n'; +import * as analytics from '@edx/frontend-platform/analytics'; +import IdVerificationContext from '../IdVerificationContext'; +import CollapsibleImageHelp from '../CollapsibleImageHelp'; + +jest.mock('jslib-html5-camera-photo'); +jest.mock('@tensorflow-models/blazeface'); +jest.mock('@edx/frontend-platform/analytics'); + +analytics.sendTrackEvent = jest.fn(); + +window.HTMLMediaElement.prototype.play = () => {}; + +const IntlCollapsible = injectIntl(CollapsibleImageHelp); + +const history = createMemoryHistory(); + +describe('CollapsibleImageHelpPanel', () => { + const defaultProps = { + intl: {}, + isPortrait: true, + }; + + const contextValue = { + shouldUseCamera: true, + setShouldUseCamera: jest.fn(), + optimizelyExperimentName: '', + mediaAccess: 'granted', + }; + + afterEach(() => { + cleanup(); + }); + + it('does not return if not part of experiment', async () => { + await act(async () => render(( + + + + + + + + ))); + + const titleText = screen.queryByText('Upload a Photo Instead'); + expect(titleText).not.toBeInTheDocument(); + }); + + it('does not return if media access denied or unsupported', async () => { + let titleText = ''; + contextValue.mediaAccess = 'denied'; + await act(async () => render(( + + + + + + + + ))); + + titleText = screen.queryByText('Upload a Photo Instead'); + expect(titleText).not.toBeInTheDocument(); + + contextValue.mediaAccess = 'unsupported'; + await act(async () => render(( + + + + + + + + ))); + + titleText = screen.queryByText('Upload a Photo Instead'); + expect(titleText).not.toBeInTheDocument(); + }); + + it('shows the correct text if user should switch to upload', async () => { + contextValue.optimizelyExperimentName = 'test'; + contextValue.mediaAccess = 'granted'; + await act(async () => render(( + + + + + + + + ))); + + const titleText = screen.getByText('Upload a Photo Instead'); + expect(titleText).toBeInTheDocument(); + const helpText = screen.getByTestId('help-text'); + expect(helpText.textContent).toContain('If you are having trouble using the photo capture above'); + const button = screen.getByTestId('toggle-button'); + expect(button).toHaveTextContent('Switch to Upload Mode'); + }); + + it('shows the correct text if user should switch to camera', async () => { + contextValue.optimizelyExperimentName = 'test'; + contextValue.mediaAccess = 'granted'; + contextValue.shouldUseCamera = false; + await act(async () => render(( + + + + + + + + ))); + + const titleText = screen.getByText('Use Your Camera Instead'); + expect(titleText).toBeInTheDocument(); + const helpText = screen.getByTestId('help-text'); + expect(helpText.textContent).toContain('If you are having trouble uploading a photo above'); + const button = screen.getByTestId('toggle-button'); + expect(button).toHaveTextContent('Switch to Camera Mode'); + }); + + it('shows the correct text if user should switch to camera with pending media access', async () => { + contextValue.optimizelyExperimentName = 'test'; + contextValue.mediaAccess = 'pending'; + contextValue.shouldUseCamera = false; + await act(async () => render(( + + + + + + + + ))); + + const titleText = screen.getByText('Use Your Camera Instead'); + expect(titleText).toBeInTheDocument(); + const helpText = screen.getByTestId('help-text'); + expect(helpText.textContent).toContain('If you are having trouble uploading a photo above'); + const accessLink = screen.getByTestId('access-link'); + fireEvent.click(accessLink); + expect(history.location.pathname).toEqual('/request-camera-access'); + }); +}); diff --git a/src/id-verification/tests/panels/ChooseModePanel.test.jsx b/src/id-verification/tests/panels/ChooseModePanel.test.jsx new file mode 100644 index 0000000..11bcbe4 --- /dev/null +++ b/src/id-verification/tests/panels/ChooseModePanel.test.jsx @@ -0,0 +1,93 @@ +import React from 'react'; +import { Router } from 'react-router-dom'; +import { createMemoryHistory } from 'history'; +import { + render, cleanup, act, screen, +} from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n'; +import IdVerificationContext from '../../IdVerificationContext'; +import ChooseModePanel from '../../panels/ChooseModePanel'; + +jest.mock('@edx/frontend-platform/analytics', () => ({ + sendTrackEvent: jest.fn(), +})); + +const IntlChooseModePanel = injectIntl(ChooseModePanel); + +const history = createMemoryHistory(); + +describe('ChooseModePanel', () => { + const defaultProps = { + intl: {}, + }; + + const contextValue = { + optimizelyExperimentName: 'test', + shouldUseCamera: false, + }; + + afterEach(() => { + cleanup(); + }); + + it('renders correctly', async () => { + await act(async () => render(( + + + + + + + + ))); + + // check that radio button for upload is selected + const uploadRadioButton = await screen.findByLabelText('Upload photos from my device'); + expect(uploadRadioButton).toBeChecked(); + + // check that if upload is selected, next button goes to correct screen + const nextButton = await screen.findByTestId('next-button'); + expect(nextButton.getAttribute('href')).toEqual('/take-portrait-photo'); + }); + + it('renders correctly if user wants to use camera', async () => { + contextValue.shouldUseCamera = true; + + await act(async () => render(( + + + + + + + + ))); + + // check that radio button for camera is selected + const cameraRadioButton = await screen.findByLabelText('Take pictures using my camera'); + expect(cameraRadioButton).toBeChecked(); + + // check that if upload is selected, next button goes to correct screen + const nextButton = await screen.findByTestId('next-button'); + expect(nextButton.getAttribute('href')).toEqual('/request-camera-access'); + }); + + it('redirects if user is not part of experiment', async () => { + contextValue.optimizelyExperimentName = ''; + + await act(async () => render(( + + + + + + + + ))); + + // check that radio button is not in document + const cameraRadioButton = await screen.queryByLabelText('Take pictures using my camera'); + expect(cameraRadioButton).not.toBeInTheDocument(); + }); +}); diff --git a/src/id-verification/tests/panels/RequestCameraAccessPanel.test.jsx b/src/id-verification/tests/panels/RequestCameraAccessPanel.test.jsx index e02998f..e329fc2 100644 --- a/src/id-verification/tests/panels/RequestCameraAccessPanel.test.jsx +++ b/src/id-verification/tests/panels/RequestCameraAccessPanel.test.jsx @@ -181,4 +181,42 @@ describe('RequestCameraAccessPanel', () => { const text = await screen.findByTestId('camera-failure-instructions'); expect(text).toHaveTextContent(/Open the Flash Player/); }); + + it('reroutes correctly to portrait context', async () => { + contextValue.mediaAccess = 'granted'; + history.location.state = { fromPortraitCapture: true }; + + Bowser.parse = jest.fn().mockReturnValue({ browser: { name: '' } }); + await act(async () => render(( + + + + + + + + ))); + const button = await screen.findByTestId('next-button'); + fireEvent.click(button); + expect(history.location.pathname).toEqual('/portrait-photo-context'); + }); + + it('reroutes correctly to ID context', async () => { + contextValue.mediaAccess = 'granted'; + history.location.state = { fromIdCapture: true }; + + Bowser.parse = jest.fn().mockReturnValue({ browser: { name: '' } }); + await act(async () => render(( + + + + + + + + ))); + const button = await screen.findByTestId('next-button'); + fireEvent.click(button); + expect(history.location.pathname).toEqual('/id-context'); + }); }); diff --git a/src/id-verification/tests/panels/TakeIdPhotoPanel.test.jsx b/src/id-verification/tests/panels/TakeIdPhotoPanel.test.jsx index 07899d6..ccbf0db 100644 --- a/src/id-verification/tests/panels/TakeIdPhotoPanel.test.jsx +++ b/src/id-verification/tests/panels/TakeIdPhotoPanel.test.jsx @@ -81,4 +81,26 @@ describe('TakeIdPhotoPanel', () => { fireEvent.click(button); expect(history.location.pathname).toEqual('/summary'); }); + + it('shows correct text if user should use upload', async () => { + contextValue.optimizelyExperimentName = 'test'; + contextValue.shouldUseCamera = false; + + await act(async () => render(( + + + + + + + + ))); + + // check that upload title and text are correct + const title = await screen.findByText('Upload a Photo of Your ID'); + expect(title).toBeVisible(); + + const text = await screen.findByTestId('upload-text'); + expect(text.textContent).toContain('Please upload an ID photo'); + }); }); diff --git a/src/id-verification/tests/panels/TakePortraitPhotoPanel.test.jsx b/src/id-verification/tests/panels/TakePortraitPhotoPanel.test.jsx index 88a4ce5..49bdf82 100644 --- a/src/id-verification/tests/panels/TakePortraitPhotoPanel.test.jsx +++ b/src/id-verification/tests/panels/TakePortraitPhotoPanel.test.jsx @@ -27,6 +27,7 @@ describe('TakePortraitPhotoPanel', () => { const contextValue = { facePhotoFile: null, setFacePhotoFile: jest.fn(), + setShouldUseCamera: jest.fn(), }; afterEach(() => { @@ -49,6 +50,7 @@ describe('TakePortraitPhotoPanel', () => { it('shows next button after photo is taken and routes to IdContextPanel', async () => { contextValue.facePhotoFile = 'test.jpg'; + contextValue.shouldUseCamera = true; await act(async () => render(( @@ -80,4 +82,26 @@ describe('TakePortraitPhotoPanel', () => { fireEvent.click(button); expect(history.location.pathname).toEqual('/summary'); }); + + it('shows correct text if user should use upload', async () => { + contextValue.optimizelyExperimentName = 'test'; + contextValue.shouldUseCamera = false; + + await act(async () => render(( + + + + + + + + ))); + + // check that upload title and text are correct + const title = await screen.findByText('Upload a Photo of Yourself'); + expect(title).toBeVisible(); + + const text = await screen.findByTestId('upload-text'); + expect(text.textContent).toContain('Please upload a portrait photo'); + }); });