Merge pull request #289 from edx/bseverino/idv-improvements

Add i18n and UX improvements to IDV
This commit is contained in:
Bianca Severino
2020-08-11 11:43:23 -04:00
committed by GitHub
11 changed files with 178 additions and 95 deletions

View File

@@ -67,7 +67,11 @@ class Camera extends React.Component {
/>
</div>
<button
className='btn btn-primary camera-btn'
className={`btn camera-btn ${
this.state.dataUri ?
'btn-outline-primary'
: 'btn-primary'
}`}
accessKey='c'
onClick={() => {
this.takePhoto();

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import messages from './IdVerification.messages';
@@ -10,13 +10,22 @@ function ExistingRequest(props) {
<h3 aria-level="1" tabIndex="-1">
{props.intl.formatMessage(messages['id.verification.existing.request.title'])}
</h3>
{props.status === 'pending' || props.status == 'approved'
{props.status === 'pending' || props.status === 'approved'
? <p>{props.intl.formatMessage(messages['id.verification.existing.request.pending.text'])}</p>
: <p>{props.intl.formatMessage(messages['id.verification.existing.request.denied.text'])}</p>
: <FormattedMessage
id="id.verification.existing.request.denied.text"
defaultMessage="You cannot verify your identity at this time. If you have yet to activate your account, please check your spam folder for the activation email from {email}."
description="Text that displays when user is denied from making a request, and to check their email for an activation email."
values={{
email: <strong>no-reply@registration.edx.org</strong>,
}}
/>
}
<a className="btn btn-primary" href={`${getConfig().LMS_BASE_URL}/dashboard`}>
{props.intl.formatMessage(messages['id.verification.return'])}
</a>
<div className="action-row">
<a className="btn btn-primary mt-3" href={`${getConfig().LMS_BASE_URL}/dashboard`}>
{props.intl.formatMessage(messages['id.verification.return.dashboard'])}
</a>
</div>
</div>
);
}

View File

@@ -68,14 +68,9 @@ const messages = defineMessages({
},
'id.verification.existing.request.pending.text': {
id: 'id.verification.existing.request.pending.text',
defaultMessage: 'You have already submitted your verification information. You will see a message on your dashboard when the verification process is complete (usually within 1-2 days).',
defaultMessage: 'You have already submitted your verification information. You will see a message on your dashboard when the verification process is complete (usually within 5 days).',
description: 'Text that displays when user has a pending or approved request.',
},
'id.verification.existing.request.denied.text': {
id: 'id.verification.existing.request.denied.text',
defaultMessage: 'You cannot verify your identity at this time.',
description: 'Text that displays when user is denied from making a request.',
},
'id.verification.photo.take': {
id: 'id.verification.photo.take',
defaultMessage: 'Take Photo',
@@ -91,6 +86,16 @@ const messages = defineMessages({
defaultMessage: 'Camera Permissions',
description: 'Title for the Camera Access page.',
},
'id.verification.camera.access.title.success': {
id: 'id.verification.camera.access.title.success',
defaultMessage: 'Camera Access Enabled',
description: 'Title for the Camera Access page when camera is enabled.',
},
'id.verification.camera.access.title.failed': {
id: 'id.verification.camera.access.title.failed',
defaultMessage: 'Camera Access Failed',
description: 'Title for the Camera Access page when camera access is denied or unavailable.',
},
'id.verification.camera.access.click.allow': {
id: 'id.verification.camera.access.click.allow',
defaultMessage: 'Please make sure to click "Allow"',
@@ -158,12 +163,12 @@ const messages = defineMessages({
},
'id.verification.portrait.photo.title.camera': {
id: 'id.verification.portrait.photo.title.camera',
defaultMessage: 'Take Your Photo',
defaultMessage: 'Take a Photo of Yourself',
description: 'Title for the Portrait Photo page if camera access is enabled.',
},
'id.verification.portrait.photo.title.upload': {
id: 'id.verification.portrait.photo.title.upload',
defaultMessage: 'Upload Your Portrait Photo',
defaultMessage: 'Upload a Photo of Yourself',
description: 'Title for the Portrait Photo page if camera access is disabled.',
},
'id.verification.portrait.photo.preview.alt': {
@@ -208,7 +213,7 @@ const messages = defineMessages({
},
'id.verification.id.tips.description': {
id: 'id.verification.id.tips.description',
defaultMessage: 'Next you\'ll need an eligible ID photo, make sure that:',
defaultMessage: 'Next, we\'ll need you to take a photo of a valid ID that includes your name. Please have your ID ready. Make sure that:',
description: 'Description for the ID Tips page.',
},
'id.verification.id.tips.list.well.lit': {
@@ -223,12 +228,12 @@ const messages = defineMessages({
},
'id.verification.id.photo.title.camera': {
id: 'id.verification.id.photo.title.camera',
defaultMessage: 'Take ID Photo',
defaultMessage: 'Take a Photo of Your ID',
description: 'Title for the ID Photo page if camera access is enabled.',
},
'id.verification.id.photo.title.upload': {
id: 'id.verification.id.photo.title.upload',
defaultMessage: 'Upload Your ID Photo',
defaultMessage: 'Upload a Photo of Your ID',
description: 'Title for the ID Photo page if camera access is disabled.',
},
'id.verification.id.photo.preview.alt': {
@@ -253,9 +258,29 @@ const messages = defineMessages({
},
'id.verification.account.name.instructions': {
id: 'id.verification.account.name.instructions',
defaultMessage: 'Please check the Account Name below to ensure it matches the name on your ID. If not, click "Edit".',
defaultMessage: 'The name on your account and the name on your ID must be an exact match. If not, please click "No" to update your account name.',
description: 'Text to verify that the account name matches the name on the ID photo.',
},
'id.verification.account.name.radio.label': {
id: 'id.verification.account.name.radio.label',
defaultMessage: 'Does the name on your ID match the Account Name below?',
description: 'Question to ask the user whether their account name match the name on their ID card.',
},
'id.verification.account.name.radio.yes': {
id: 'id.verification.account.name.radio.yes',
defaultMessage: 'Yes',
description: 'The radio button that says the account name matches.',
},
'id.verification.account.name.radio.no': {
id: 'id.verification.account.name.radio.no',
defaultMessage: 'No',
description: 'The radio button that says the account name does not match.',
},
'id.verification.account.name.error': {
id: 'id.verification.account.name.error',
defaultMessage: 'Please update account name to match the name on your ID.',
description: 'Error that shows when the user needs to update their account name to match the name on their ID.',
},
'id.verification.account.name.warning.prefix': {
id: 'id.verification.account.name.warning.prefix',
defaultMessage: 'Please Note:',
@@ -268,7 +293,7 @@ const messages = defineMessages({
},
'id.verification.account.name.label': {
id: 'id.verification.account.name.label',
defaultMessage: 'Name on ID',
defaultMessage: 'Account Name',
description: 'Label for account name input.',
},
'id.verification.account.name.edit': {
@@ -283,7 +308,7 @@ const messages = defineMessages({
},
'id.verification.account.name.save': {
id: 'id.verification.account.name.save',
defaultMessage: 'Save',
defaultMessage: 'Save and Next',
description: 'Button to save the account name.',
},
'id.verification.review.title': {
@@ -328,8 +353,8 @@ const messages = defineMessages({
},
'id.verification.review.confirm': {
id: 'id.verification.review.confirm',
defaultMessage: 'Confirm',
description: 'Button to confirm all information is correct.',
defaultMessage: 'Submit',
description: 'Button to confirm all information is correct and submit.',
},
'id.verification.submitted.title': {
id: 'id.verification.submitted.title',

View File

@@ -12,6 +12,14 @@
max-height: 10rem;
}
}
.form-check {
padding: 0.5rem 0.5rem 1rem;
.form-check-label {
margin-left: 0.5rem;
padding-top: 0.2rem;
font-weight: normal;
}
}
}
.action-row {
display: flex;

View File

@@ -1,40 +1,42 @@
import React, { useContext, useState, useEffect, useRef } from 'react';
import { Input, Button } from '@edx/paragon';
import { Form } from '@edx/paragon';
import { Link } from 'react-router-dom';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useNextPanelSlug } from '../routing-utilities';
import BasePanel from './BasePanel';
import { IdVerificationContext } from '../IdVerificationContext';
import ImagePreview from '../ImagePreview';
import messages from '../IdVerification.messages';
function GetNameIdPanel(props) {
const panelSlug = 'get-name-id';
const [isEditing, setIsEditing] = useState(false);
const [nameMatches, setNameMatches] = useState(true);
const nameInputRef = useRef();
const nextPanelSlug = useNextPanelSlug(panelSlug);
useEffect(() => {
if (isEditing && nameInputRef.current) {
nameInputRef.current.focus();
}
}, [isEditing]);
const {
nameOnAccount, userId, idPhotoName, setIdPhotoName, idPhotoFile,
nameOnAccount, userId, idPhotoName, setIdPhotoName,
} = useContext(IdVerificationContext);
const nameOnAccountValue = nameOnAccount || '';
const invalidName = !nameMatches && (!idPhotoName || idPhotoName === nameOnAccount);
const handleClick = () => {
setIsEditing(true);
sendTrackEvent('edx.id_verification.name_change', {
category: 'id_verification',
user_id: userId,
});
};
useEffect(() => {
setIdPhotoName('');
}, []);
useEffect(() => {
if (!nameMatches && nameInputRef.current) {
nameInputRef.current.focus();
}
if (!nameMatches) {
sendTrackEvent('edx.id_verification.name_change', {
category: 'id_verification',
user_id: userId,
});
}
}, [nameMatches]);
return (
<BasePanel
@@ -45,53 +47,75 @@ function GetNameIdPanel(props) {
{props.intl.formatMessage(messages['id.verification.account.name.instructions'])}
</p>
<div className="alert alert-warning">
<FormattedMessage
id="id.verification.account.name.warning"
defaultMessage="{prefix} Any edit to your name will be saved to your account and can be reviewed on {accountSettings}."
description="Warning that any edit to the user's name will be saved to the account."
values={{
prefix: <strong>{props.intl.formatMessage(messages['id.verification.account.name.warning.prefix'])}</strong>,
accountSettings: <Link to="/">{props.intl.formatMessage(messages['id.verification.account.name.settings'])}</Link>,
}}
/>
</div>
<div className="form-group">
<label htmlFor="photo-id-name">
{props.intl.formatMessage(messages['id.verification.account.name.label'])}
</label>
<div className="d-flex">
<Input
id="photo-id-name"
<Form>
<Form.Group>
<Form.Label htmlFor="nameMatchesYes">
{props.intl.formatMessage(messages['id.verification.account.name.radio.label'])}
</Form.Label>
<Form.Row>
<Form.Check
type="radio"
id="nameMatchesYes"
name="nameMatches"
data-testid="name-matches-yes"
label={props.intl.formatMessage(messages['id.verification.account.name.radio.yes'])}
checked={nameMatches}
inline
onChange={() => {
setNameMatches(true);
setIdPhotoName('');
}}
/>
<Form.Check
type="radio"
id="nameMatchesNo"
name="nameMatches"
data-testid="name-matches-no"
label={props.intl.formatMessage(messages['id.verification.account.name.radio.no'])}
inline
checked={!nameMatches}
onChange={() => setNameMatches(false)}
/>
</Form.Row>
</Form.Group>
<Form.Group>
<Form.Label htmlFor="photo-id-name">
{props.intl.formatMessage(messages['id.verification.account.name.label'])}
</Form.Label>
<Form.Control
controlId="photo-id-name"
size="lg"
type="text"
ref={nameInputRef}
disabled={!isEditing}
readOnly={!isEditing}
value={idPhotoName || nameOnAccountValue}
readOnly={nameMatches}
isInvalid={invalidName}
aria-describedby="photo-id-name-feedback"
value={
!nameMatches ?
idPhotoName || nameOnAccountValue
: nameOnAccountValue
}
onChange={e => setIdPhotoName(e.target.value)}
data-testid="name-input"
/>
{!isEditing && (
<Button
className="btn-link px-0 ml-3"
onClick={handleClick}
data-testid="edit-button"
>
{props.intl.formatMessage(messages['id.verification.account.name.edit'])}
</Button>
)}
</div>
</div>
<ImagePreview
id="photo-of-id"
src={idPhotoFile}
alt={props.intl.formatMessage(messages['id.verification.account.name.photo.alt'])}
/>
<Form.Control.Feedback id="photo-id-name-feedback" type="invalid">
{props.intl.formatMessage(messages['id.verification.account.name.error'])}
</Form.Control.Feedback>
</Form.Group>
</Form>
<div className="action-row">
<Link to={nextPanelSlug} className="btn btn-primary" data-testid="next-button">
{isEditing ? props.intl.formatMessage(messages['id.verification.account.name.save']) : props.intl.formatMessage(messages['id.verification.next'])}
<Link
to={nextPanelSlug}
className={`btn btn-primary ${invalidName && 'disabled'}`}
data-testid="next-button"
aria-disabled={invalidName}
>
{
!nameMatches ?
props.intl.formatMessage(messages['id.verification.account.name.save'])
: props.intl.formatMessage(messages['id.verification.next'])
}
</Link>
</div>
</BasePanel>

View File

@@ -41,10 +41,19 @@ function RequestCameraAccessPanel(props) {
}
}, []);
const getTitle = () => {
if (mediaAccess === MEDIA_ACCESS.GRANTED) {
return props.intl.formatMessage(messages['id.verification.camera.access.title.success']);
} else if ([MEDIA_ACCESS.UNSUPPORTED, MEDIA_ACCESS.DENIED].includes(mediaAccess)) {
return props.intl.formatMessage(messages['id.verification.camera.access.title.failed']);
}
return props.intl.formatMessage(messages['id.verification.camera.access.title']);
};
return (
<BasePanel
name={panelSlug}
title={props.intl.formatMessage(messages['id.verification.camera.access.title'])}
title={getTitle()}
>
{mediaAccess === MEDIA_ACCESS.PENDING && (
<div>

View File

@@ -32,7 +32,7 @@ function ReviewRequirementsPanel(props) {
</p>
<div className="card mb-4 shadow requirements">
<div className="card-body">
<h6>
<h6 aria-level="3">
{props.intl.formatMessage(messages['id.verification.requirements.card.device.title'])}
</h6>
<p className="mb-0">
@@ -49,7 +49,7 @@ function ReviewRequirementsPanel(props) {
</div>
<div className="card mb-4 shadow requirements">
<div className="card-body">
<h6>
<h6 aria-level="3">
{props.intl.formatMessage(messages['id.verification.requirements.card.id.title'])}
</h6>
<p className="mb-0">
@@ -57,16 +57,16 @@ function ReviewRequirementsPanel(props) {
</p>
</div>
</div>
<h4 className="mb-3">
<h4 aria-level="2" className="mb-3">
{props.intl.formatMessage(messages['id.verification.privacy.title'])}
</h4>
<h6>
<h6 aria-level="3">
{props.intl.formatMessage(messages['id.verification.privacy.need.photo.question'])}
</h6>
<p>
{props.intl.formatMessage(messages['id.verification.privacy.need.photo.answer'])}
</p>
<h6>
<h6 aria-level="3">
{props.intl.formatMessage(messages['id.verification.privacy.do.with.photo.question'])}
</h6>
<p>

View File

@@ -70,7 +70,7 @@ function SummaryPanel(props) {
alt={props.intl.formatMessage(messages['id.verification.review.portrait.alt'])}
/>
<Link
className="btn btn-inverse-primary shadow"
className="btn btn-outline-primary"
to={{
pathname: 'take-portrait-photo',
state: { fromSummary: true },
@@ -89,7 +89,7 @@ function SummaryPanel(props) {
alt={props.intl.formatMessage(messages['id.verification.review.id.alt'])}
/>
<Link
className="btn btn-inverse-primary shadow"
className="btn btn-outline-primary"
to={{
pathname: 'take-id-photo',
state: { fromSummary: true },

View File

@@ -44,12 +44,12 @@ function TakeIdPhotoPanel(props) {
</div>
)}
</div>
{shouldUseCamera && <CameraHelp />}
<div className="action-row" style={{ visibility: idPhotoFile ? 'unset' : 'hidden' }}>
<Link to={nextPanelSlug} className="btn btn-primary" data-testid="next-button">
{props.intl.formatMessage(messages['id.verification.next'])}
</Link>
</div>
{shouldUseCamera && <CameraHelp />}
</BasePanel>
);
}

View File

@@ -44,12 +44,12 @@ function TakePortraitPhotoPanel(props) {
</div>
)}
</div>
{shouldUseCamera && <CameraHelp />}
<div className="action-row" style={{ visibility: facePhotoFile ? 'unset' : 'hidden' }}>
<Link to={nextPanelSlug} className="btn btn-primary" data-testid="next-button">
{props.intl.formatMessage(messages['id.verification.next'])}
</Link>
</div>
{shouldUseCamera && <CameraHelp />}
</BasePanel>
);
}

View File

@@ -43,13 +43,17 @@ describe('GetNameIdPanel', () => {
</IntlProvider>
</Router>
)));
const button = await screen.findByTestId('edit-button');
const yesButton = await screen.findByTestId('name-matches-yes');
const noButton = await screen.findByTestId('name-matches-no');
const input = await screen.findByTestId('name-input');
expect(input).toHaveProperty('disabled', true);
fireEvent.click(button);
expect(input).toHaveProperty('disabled', false);
expect(input).toHaveProperty('readOnly', true);
fireEvent.click(noButton);
expect(input).toHaveProperty('readOnly', false);
fireEvent.change(input, { target: { value: 'test change' } });
expect(contextValue.setIdPhotoName).toHaveBeenCalled();
fireEvent.click(yesButton);
expect(input).toHaveProperty('readOnly', true);
expect(contextValue.setIdPhotoName).toHaveBeenCalled();
});
it('routes to SummaryPanel', async () => {